Compare commits

...
114 Commits
Author SHA1 Message Date
Vitor Pamplona 1793fe40bf v0.64.0 2023-06-29 18:13:48 -04:00
Vitor Pamplona 0857695332 Moves global to home as a list and search to the top bar. 2023-06-29 18:13:09 -04:00
Vitor Pamplona a7b3bac8f8 Slims down the live headers 2023-06-29 18:12:22 -04:00
Vitor Pamplona e6f122d8b7 BugFix for trimmed paragraphs losing indentation 2023-06-29 16:46:44 -04:00
Vitor Pamplona b2cdc2ddef BugFix: Not showing display name on [n] tags 2023-06-29 16:28:47 -04:00
Vitor Pamplona 94057059bb Logging out the time to display reactions 2023-06-29 16:17:29 -04:00
Vitor Pamplona 927ef17896 Improved AsyncImage loading states. 2023-06-29 15:09:08 -04:00
Vitor Pamplona 33f915879b Makes NIP05 work in domain names only (No need for the _@ in the "_@domain" address) 2023-06-29 15:08:46 -04:00
Vitor Pamplona 98bca48359 Changes to not preview images when CanPreview is false. 2023-06-29 14:19:35 -04:00
Vitor Pamplona 171f763908 Additional checks for main thread in Notifications 2023-06-29 12:42:11 -04:00
Vitor Pamplona 605d83e530 v0.63.0 2023-06-29 12:32:45 -04:00
Vitor Pamplona 5f710b7304 Adds #tunestr icon 2023-06-29 12:29:08 -04:00
Vitor Pamplona 1c6593fb17 Adds alt-text to screenshots 2023-06-29 12:15:03 -04:00
Vitor Pamplona 33fe34d3b3 updates screenshot titles 2023-06-29 12:13:56 -04:00
Vitor Pamplona e257fec92c Breaking screenshots in two lines 2023-06-29 12:10:26 -04:00
Vitor Pamplona 6a70a6c72f Showing screenshots in the homepage 2023-06-29 12:09:05 -04:00
Vitor Pamplona 270d6b411a Updating Amethyst Logo to Zitron's 2023-06-29 12:00:20 -04:00
Vitor PamplonaandGitHub 803b1e861e Merge pull request #480 from greenart7c3/main
added followed tags in profile screen
2023-06-29 10:03:33 -04:00
Vitor Pamplona 7300dc3d78 No need to point to the release section after the GitHub badge has been added. 2023-06-29 09:52:24 -04:00
Vitor Pamplona 405d64402b decreases height 2023-06-29 09:42:55 -04:00
Vitor Pamplona fe1bff1074 removes space between badges 2023-06-29 09:41:44 -04:00
Vitor Pamplona 2952ca1978 Reverting change 2023-06-29 09:39:14 -04:00
Vitor Pamplona 32dcd7747b slight change in borders 2023-06-29 09:38:20 -04:00
Vitor Pamplona eca5a1de15 Matching obtainium's and github's badge 2023-06-29 09:36:55 -04:00
Vitor Pamplona 886442ac52 trying to align obtainium's image on the readme 2023-06-29 09:35:20 -04:00
Vitor Pamplona 8521611008 Increasing borders a bit 2023-06-29 09:32:38 -04:00
Vitor Pamplona df647e4047 Fixing borders 2023-06-29 09:31:44 -04:00
Vitor Pamplona af26026d25 adds borders 2023-06-29 09:29:55 -04:00
Vitor Pamplona 43be55f3d6 reduces the size of the badge 2023-06-29 09:26:32 -04:00
Vitor Pamplona 9b3cb92cb3 Adds obtainium and APK to the links 2023-06-29 09:25:35 -04:00
Vitor Pamplona f2b0ce5fd0 adds obtainium image 2023-06-29 09:24:42 -04:00
Vitor Pamplona 787ca98daf v0.62.8 2023-06-28 19:06:05 -04:00
Vitor Pamplona 71517dc454 Formatting 2023-06-28 18:54:42 -04:00
Vitor Pamplona 949301d58f Mocking Looper to use the checkMainThread in tests 2023-06-28 18:54:33 -04:00
Vitor Pamplona cf8ac4faae Merge remote-tracking branch 'origin/HEAD'
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2023-06-28 18:32:59 -04:00
Vitor Pamplona ad3aa124fc Performance improvements additional MainThread checks. 2023-06-28 18:32:10 -04:00
Vitor Pamplona 2326aa9f7c Runs the translation update in the main thread 2023-06-28 14:52:01 -04:00
Vitor Pamplona acfd687d74 Avoids blinking the nip05 address 2023-06-28 14:51:34 -04:00
Vitor Pamplona 9b9183395d BugFix: Ignoring case when comparing original and translated versions. 2023-06-28 14:51:02 -04:00
greenart7c3andGitHub 45b4902b2b Merge branch 'vitorpamplona:main' into main 2023-06-28 15:40:36 -03:00
greenart7c3 6056d60d32 added followed tags in profile screen 2023-06-28 15:37:24 -03:00
Vitor PamplonaandGitHub 61af248c09 Merge pull request #479 from ZsZolee/main
Additional Hungarian translation
2023-06-28 11:17:16 -04:00
Vitor PamplonaandGitHub 5a580db1b8 Merge pull request #478 from greenart7c3/main
fix rendering url of highlight note
2023-06-28 11:17:03 -04:00
Vitor PamplonaandGitHub a72ae49e93 Merge pull request #477 from believethehype/cashuredeem
Add copy button to Cashu Rendering, better default error message
2023-06-28 11:16:08 -04:00
Vitor Pamplona 40a01219cc Moves RichText Modifiers to singleton classes. 2023-06-28 11:13:38 -04:00
Vitor Pamplona c652390a9c Updates URLPreview in the main thread. 2023-06-28 11:09:54 -04:00
Vitor Pamplona 0916a97526 Users rememberPagerState instead of just remember 2023-06-28 11:09:19 -04:00
Vitor Pamplona d76ef0a1ca updates firebase to the newest version 2023-06-28 11:06:56 -04:00
ZoltanandGitHub 3a70ce1587 Additional Hungarian translation 2023-06-28 16:52:39 +02:00
greenart7c3 9f96e84378 fix rendering url of highlight note 2023-06-28 07:58:19 -03:00
Believethehype 985771beba Add copy button to Cashu Rendering, better default error message 2023-06-28 11:49:14 +02:00
Vitor PamplonaandGitHub 3967633f77 Merge pull request #475 from davotoula/translation_updates
Updated se/de/cs translations
2023-06-27 15:05:31 -04:00
Vitor Pamplona ee5699c2de v0.62.7 2023-06-27 13:16:28 -04:00
Vitor Pamplona b8f4d9f0bb BugFix on zapraiser completed bar 2023-06-27 13:14:47 -04:00
Vitor Pamplona 8b1de47517 v0.62.6 2023-06-27 12:27:06 -04:00
Vitor Pamplona c6ff514ba2 Logs rendering time of sections of the post 2023-06-27 12:26:57 -04:00
Vitor Pamplona 50f2d18b61 Moves state assignments to the main thread. 2023-06-27 12:05:11 -04:00
Vitor Pamplona fa7ccdfc20 Reduces full screen updates to once a second. 2023-06-27 12:02:55 -04:00
David Kaspar fe02519737 Updated se/de/cs translations 2023-06-27 13:30:37 +01:00
Vitor Pamplona f2e9fbebea v0.62.5 2023-06-26 22:42:42 -04:00
Vitor Pamplona 52af109b4e Minor adjustments in threading 2023-06-26 20:38:06 -04:00
Vitor Pamplona 833e6bc3e1 Moves cashu processing to a state class to account for errors. 2023-06-26 20:17:35 -04:00
Vitor Pamplona 9bed6fdb9a v0.62.4 2023-06-26 18:22:07 -04:00
Vitor Pamplona a4a867ebcd Achieves a bigger space if the hide system bars is placed outside the Dialog. 2023-06-26 18:18:04 -04:00
Vitor Pamplona 4da2f18957 Video Dialog in full screen 2023-06-26 17:44:36 -04:00
Vitor Pamplona 4141a52d51 Allows video dialog on streaming 2023-06-26 15:40:13 -04:00
Vitor Pamplona 4dbc71d577 Removes padding from the live header 2023-06-26 15:39:32 -04:00
Vitor Pamplona 3b5d792562 Extracts Cashu logic into a service class. 2023-06-26 15:27:04 -04:00
Vitor Pamplona dd7f3225c6 Preloading channel from LocalCache 2023-06-26 14:59:18 -04:00
Vitor Pamplona 3477e7b8ba No need to remember Modifier 2023-06-26 14:55:55 -04:00
Vitor Pamplona a3782bb459 Remove image from the live event and use only the author picture in the header. 2023-06-26 14:47:22 -04:00
Vitor Pamplona d4003eee04 Adjusts Profile images to crop and not fit 2023-06-26 14:46:56 -04:00
Vitor Pamplona c9bbb66284 Adjusting logout message. 2023-06-26 14:33:21 -04:00
Vitor PamplonaandGitHub 1498fa9868 Merge pull request #474 from greenart7c3/main
add confirmation dialog before logout
2023-06-26 14:27:15 -04:00
Vitor PamplonaandGitHub 26b493aa1d Merge pull request #473 from believethehype/cashuredeem
Melt cashu tokens in Mint directly instead of using webservice
2023-06-26 14:26:44 -04:00
Vitor Pamplona f9d652f849 Performance Improvements 2023-06-26 14:25:46 -04:00
greenart7c3 30b0ee444f add confirmation dialog before logout 2023-06-26 14:38:44 -03:00
Believethehype bf8341ef58 Merge remote-tracking branch 'upstream/main' into cashuredeem 2023-06-26 18:19:01 +02:00
Believethehype f9f1b0d98e Melt cashu tokens in Mint directly instead of using webservice 2023-06-26 18:18:45 +02:00
Vitor Pamplona 743a4c9d87 Using crossfade to show live activities 2023-06-26 09:12:10 -04:00
Vitor PamplonaandGitHub 275baa7497 Merge pull request #472 from believethehype/cashuredeem
Parse and Show Sat Amount from Cashu tokens
2023-06-26 08:44:19 -04:00
Vitor Pamplona ac1fb879c3 Bugfix for not updating user picture and name after a message arrives 2023-06-26 08:39:46 -04:00
Vitor Pamplona 270ad56450 Aligns LIVE marker to the right in the feed. 2023-06-26 08:38:29 -04:00
Believethehype d155241efc Parse and Show Sat Amount from Cashu tokens 2023-06-26 14:32:08 +02:00
Vitor PamplonaandGitHub d61927cbee Merge pull request #471 from believethehype/cashuredeem
Parse Cashu Tokens and provide link to redeem
2023-06-26 06:50:56 -04:00
Vitor PamplonaandGitHub 996b68f9c6 Merge pull request #470 from Bardesss/patch-1
Update strings.xml (NL)
2023-06-26 06:49:19 -04:00
Believethehype f6913663d1 enable autopay, change parsing 2023-06-26 11:57:31 +02:00
Believethehype 58600e13a8 parse cashu tokens 2023-06-26 11:04:31 +02:00
BardesssandGitHub 54e15ad3cd Update strings.xml (NL)
Updated translation
2023-06-26 08:52:35 +00:00
Vitor Pamplona 777c652bf5 v0.62.3 (TEST) 2023-06-25 19:35:00 -04:00
Vitor Pamplona 7fb1473e21 There is no need to invalidate twice. 2023-06-25 19:34:51 -04:00
Vitor Pamplona 95db1ec84f Typo 2023-06-25 19:34:35 -04:00
Vitor Pamplona 7438973a20 Trying to improve rendering speed of chat messages. 2023-06-25 19:18:32 -04:00
Vitor Pamplona fe31855860 Minor performance improvements in reaction row 2023-06-25 19:18:11 -04:00
Vitor Pamplona d7bce4414d Crossfades changes in Reactions and Zaps 2023-06-25 19:17:48 -04:00
Vitor Pamplona b4a81c137c Removes the need for lifecycle awareness here. 2023-06-25 19:16:41 -04:00
Vitor Pamplona a6390f58d9 Removes debug info 2023-06-25 19:16:15 -04:00
Vitor Pamplona ca3f4c3ec6 Moves MaxWidth for Chat bubbles to Shapes 2023-06-25 16:49:33 -04:00
Vitor Pamplona 53bfd40a31 Simplifies hex check 2023-06-25 16:22:17 -04:00
Vitor Pamplona 798be25c43 BugFix for duplicated notes in the LocalCache 2023-06-25 16:21:36 -04:00
Vitor Pamplona ebe1bcedd0 Moves user msg Synchronization to it's own object. 2023-06-25 16:11:21 -04:00
Vitor Pamplona 4731575b87 Less memory intensive Hex Checker 2023-06-25 12:41:21 -04:00
Vitor Pamplona 383e761372 Moves LocalCache's Synchronized block to Concurrent implementations 2023-06-25 12:40:56 -04:00
Vitor Pamplona 54a95e6a4e Moves synchronized block to inner function 2023-06-25 12:38:08 -04:00
Vitor Pamplona 3105ade755 Moves Shape constants to the Shape class 2023-06-25 11:00:41 -04:00
Vitor Pamplona f651034303 Better starting point for the following observer 2023-06-24 18:08:11 -04:00
Vitor Pamplona 7554dc010c Fixes spacing from last change 2023-06-24 18:07:56 -04:00
Vitor Pamplona 70a962062b Minor code and performance improvements. 2023-06-24 18:03:04 -04:00
Vitor Pamplona 5cc7f3bf2b Correct Immutable class 2023-06-24 15:55:21 -04:00
Vitor Pamplona b040e0ca13 Moves OnlineChecker to a singleton class with cache. 2023-06-24 15:53:22 -04:00
Vitor Pamplona 775094356c Fixes new posts not showing up in the feed. 2023-06-24 10:49:34 -04:00
Vitor Pamplona 0771b179b5 Reducing the need to update the event for every two hours. 2023-06-24 10:36:42 -04:00
Vitor Pamplona 8af307e8bb Migrating the UserProfile page to the new ViewModel structure 2023-06-23 22:00:51 -04:00
Vitor Pamplona e6ec3f1ce4 - Adds picture of the User to the Stream header.
- BugFix for the Click on UserPicture event
2023-06-23 20:44:58 -04:00
143 changed files with 7126 additions and 2064 deletions
+11 -2
View File
@@ -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 |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+3 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 223
versionName "0.62.2"
versionCode 231
versionName "0.64.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -181,7 +181,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -14,7 +14,6 @@ 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.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
@@ -89,7 +88,6 @@ object ServiceManager {
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrGlobalDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
@@ -1134,7 +1134,7 @@ class Account(
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
private fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
}
@@ -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,10 +1,11 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
@@ -17,10 +18,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
}
@Immutable
class HashtagIcon(
val icon: Int,
val description: String,
@@ -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
@@ -42,19 +41,18 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
answer
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
}
fun getUserIfExists(key: String): User? {
return users[key]
if (key.isEmpty()) return null
return users.get(key)
}
fun getNoteIfExists(key: String): Note? {
@@ -84,14 +82,12 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
return notes.get(idHex) ?: run {
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
}
@@ -113,25 +109,18 @@ object LocalCache {
}
private fun isValidHexNpub(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)
@@ -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
@@ -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,6 +438,23 @@ class UserMetadata {
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -49,6 +49,8 @@ object BlurHashDecoder {
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) {
return null
}
@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
)
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Reedem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},
onProgress = {
},
onError = onError
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = client.newCall(request).execute()
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
}
}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}
@@ -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()
@@ -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),
@@ -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)
@@ -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
@@ -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)
}
}
}
}
@@ -305,6 +311,7 @@ fun loadRelayInfo(
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
@@ -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)
}
}
}
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -25,13 +26,16 @@ fun MayBeWithdrawal(lnurlWord: String) {
}
}
lnWithdrawal?.let {
ClickableWithdrawal(withdrawalString = it)
Crossfade(targetState = lnWithdrawal) {
if (it != null) {
ClickableWithdrawal(withdrawalString = it)
} else {
Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
?: Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
@Immutable
sealed class GenericLoadable<T> {
@Immutable
class Loading<T> : GenericLoadable<T>()
@Immutable
class Loaded<T>(val loaded: T) : GenericLoadable<T>()
@Immutable
class Empty<T> : GenericLoadable<T>()
@Immutable
class Error<T>(val errorMessage: String) : GenericLoadable<T>()
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,7 +28,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import java.text.NumberFormat
@Composable
@@ -35,7 +36,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
@@ -50,13 +51,16 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
}
lnInvoice?.let {
InvoicePreview(it.first, it.second)
Crossfade(targetState = lnInvoice) {
if (it != null) {
InvoicePreview(it.first, it.second)
} else {
Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
?: Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
@@ -10,6 +10,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import java.io.File
@@ -24,6 +25,8 @@ class MediaCompressor {
onReady: (Uri, String?, Long?) -> Unit,
onError: (String) -> Unit
) {
checkNotInMainThread()
if (contentType?.startsWith("video", true) == true) {
VideoCompressor.start(
context = applicationContext, // => This is required
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.foundation.border
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
@@ -16,37 +16,32 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.HeadingStyle
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -87,38 +82,6 @@ fun isValidURL(url: String?): Boolean {
}
}
internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle ->
when (level) {
0 -> textStyle.copy(
fontSize = 30.sp,
fontWeight = FontWeight.Light
)
1 -> textStyle.copy(
fontSize = 26.sp,
fontWeight = FontWeight.Light
)
2 -> textStyle.copy(
fontSize = 22.sp,
fontWeight = FontWeight.Light
)
3 -> textStyle.copy(
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
4 -> textStyle.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
5 -> textStyle.copy(
fontWeight = FontWeight.Bold
)
else -> textStyle
}
}
internal val DefaultParagraphSpacing: TextUnit = 12.sp
val richTextDefaults = RichTextStyle().resolveDefaults()
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
content.startsWith("# ") ||
@@ -157,7 +120,6 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
@@ -200,9 +162,9 @@ private fun RenderParagraph(
) {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed().toImmutableList()
paragraph.split(' ').reversed().toImmutableList()
} else {
paragraph.trim().split(' ').toImmutableList()
paragraph.split(' ').toImmutableList()
}
}
@@ -260,7 +222,7 @@ private fun parseUrls(
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
@@ -274,6 +236,9 @@ private fun RenderWord(
tags: ImmutableListOfLists<String>
) {
val type = remember(word) {
if (word == "") {
WordType.OTHER
}
if (state.imagesForPager[word] != null) {
WordType.IMAGE
} else if (state.urlSet.contains(word)) {
@@ -284,6 +249,8 @@ private fun RenderWord(
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (word.startsWith("cashuA", true)) {
WordType.CASHU
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -332,13 +299,14 @@ private fun RenderWordWithoutPreview(
when (type) {
// Don't preview Images
WordType.IMAGE -> UrlPreview(word, wordSpace)
WordType.LINK -> UrlPreview(word, wordSpace)
WordType.IMAGE -> ClickableUrl(wordSpace, word)
WordType.LINK -> ClickableUrl(wordSpace, word)
WordType.EMOJI -> RenderCustomEmoji(word, state)
// Don't offer to pay invoices
WordType.INVOICE -> NormalWord(wordSpace)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.CASHU -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
@@ -369,6 +337,7 @@ private fun RenderWordWithPreview(
WordType.EMOJI -> RenderCustomEmoji(word, state)
WordType.INVOICE -> MayBeInvoicePreview(word)
WordType.WITHDRAW -> MayBeWithdrawal(word)
WordType.CASHU -> CashuPreview(word, accountViewModel)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
@@ -394,6 +363,9 @@ private fun NormalWord(word: String) {
)
}
@Immutable
data class UrlWithExtraChars(val url: String, val extraChars: String)
@Composable
private fun NoProtocolUrlRenderer(word: String) {
val wordSpace = remember(word) {
@@ -401,7 +373,7 @@ private fun NoProtocolUrlRenderer(word: String) {
}
var linkedUrl by remember(word) {
mutableStateOf<Pair<String, String>?>(null)
mutableStateOf<UrlWithExtraChars?>(null)
}
LaunchedEffect(key1 = word) {
@@ -411,19 +383,30 @@ private fun NoProtocolUrlRenderer(word: String) {
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
linkedUrl = Pair(url, additionalChars)
launch(Dispatchers.Main) {
linkedUrl = UrlWithExtraChars(url, additionalChars)
}
}
}
}
if (linkedUrl != null) {
ClickableUrl(linkedUrl!!.first, "https://${linkedUrl!!.first}")
Text("${linkedUrl!!.second} ")
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Crossfade(targetState = linkedUrl) {
if (it != null) {
RenderUrl(it)
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@Composable
private fun RenderUrl(it: UrlWithExtraChars) {
Row() {
ClickableUrl(it.url, "https://${it.url}")
Text("${it.extraChars} ")
}
}
@@ -438,14 +421,16 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
val onClick = { link: String ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
val onClick = remember {
{ link: String ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
}
Unit
}
Unit
}
ProvideTextStyle(MarkdownTextStyle) {
@@ -517,7 +502,7 @@ private fun ObserveNIP19Event(
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
launch(Dispatchers.Main) { baseNote = note }
}
}
}
@@ -554,7 +539,7 @@ private fun ObserveNIP19User(
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
launch(Dispatchers.Main) { baseUser = user }
}
}
}
@@ -747,26 +732,34 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
}
loadedLink = LoadedBechLink(returningNote, it)
val newLink = LoadedBechLink(returningNote, it)
launch(Dispatchers.Main) {
loadedLink = newLink
}
}
}
}
if (canPreview) {
loadedLink?.let { loadedLink ->
loadedLink.baseNote?.let {
DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink)
} ?: run {
ClickableRoute(loadedLink.nip19, nav)
Crossfade(targetState = loadedLink) {
if (canPreview && it?.baseNote != null) {
Row() {
DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it)
}
} ?: run {
Text(text = remember { "$word " })
}
} else {
loadedLink?.let {
ClickableRoute(it.nip19, nav)
} ?: run {
Text(text = remember { "$word " })
} else if (it?.nip19 != null) {
Row() {
ClickableRoute(it.nip19, nav)
}
} else {
val text = remember {
if (word.length > 16) {
word.replaceRange(8, word.length - 8, ":")
} else {
"$word "
}
}
Text(text = text, maxLines = 1)
}
}
}
@@ -779,24 +772,10 @@ private fun DisplayFullNote(
nav: (String) -> Unit,
loadedLink: LoadedBechLink
) {
val borderColor = MaterialTheme.colors.subtleBorder
val modifier = remember {
Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
borderColor,
QuoteBorder
)
}
NoteCompose(
baseNote = it,
accountViewModel = accountViewModel,
modifier = modifier,
modifier = MaterialTheme.colors.replyModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
@@ -817,83 +796,103 @@ private fun DisplayFullNote(
}
}
data class HashWordWithExtra(val hashtag: String, val extras: String?)
@Composable
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
var tagSuffixPair by remember { mutableStateOf<HashWordWithExtra?>(null) }
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
if (tagSuffixPair == null) {
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
if (myTag != null) {
launch(Dispatchers.Main) {
tagSuffixPair = HashWordWithExtra(myTag, mySuffix)
}
}
}
}
}
tagSuffixPair?.let { tagPair ->
val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) }
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#${tagPair.first}")
}
},
onClick = { nav("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
Crossfade(targetState = tagSuffixPair) {
if (it != null) {
Row() {
RenderHashtag(it, nav)
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
} else {
Text(text = remember { "$word " })
}
tagPair.second?.ifBlank { "" }?.let {
Text(text = "$it ")
}
} ?: Text(text = "$word ")
}
}
@Composable
private fun RenderHashtag(
tagPair: HashWordWithExtra,
nav: (String) -> Unit
) {
val primary = MaterialTheme.colors.primary
val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag, primary) }
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#${tagPair.hashtag}")
}
},
onClick = { nav("Hashtag/${tagPair.hashtag}") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = mapOf(
myId to InlineIcon(hashtagIcon)
)
)
}
tagPair.extras?.ifBlank { "" }?.let {
Text(text = "$it ")
}
}
@Composable
private fun InlineIcon(hashtagIcon: HashtagIcon) =
InlineTextContent(
Placeholder(
width = Font17SP,
height = Font17SP,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
@@ -918,11 +917,15 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
loadedTag = LoadedTag(it, null, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(it, null, suffix)
}
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
loadedTag = LoadedTag(null, it, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(null, it, suffix)
}
}
}
}
@@ -931,19 +934,28 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
}
}
if (loadedTag == null) {
Text(
text = remember {
"$word "
Crossfade(targetState = loadedTag) {
if (it == null) {
Text(
text = remember {
"$word "
}
)
} else if (it.user != null) {
Row() {
DisplayUserFromTag(it.user, it.addedChars ?: "", nav)
}
} else if (it.note != null) {
Row() {
DisplayNoteFromTag(
it.note,
it.addedChars ?: "",
canPreview,
accountViewModel,
backgroundColor,
nav
)
}
)
} else {
loadedTag?.user?.let {
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
}
loadedTag?.note?.let {
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
}
}
}
@@ -961,15 +973,7 @@ private fun DisplayNoteFromTag(
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
),
modifier = MaterialTheme.colors.innerPostModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
@@ -991,25 +995,25 @@ private fun DisplayUserFromTag(
) {
val route = remember { "User/${baseUser.pubkeyHex}" }
val suffix = remember { "$addedChars " }
val hex = remember { baseUser.pubkeyDisplayHex() }
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName by remember(innerUserState) {
derivedStateOf {
innerUserState?.user?.toBestDisplayName() ?: ""
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
Crossfade(targetState = meta) {
Row() {
val displayName = remember(it) {
it?.bestDisplayName() ?: it?.bestUsername() ?: hex
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = it?.tags
)
}
}
val userTags by remember(innerUserState) {
derivedStateOf {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = userTags
)
}
@@ -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,6 +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.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -36,8 +37,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
@@ -50,6 +53,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
@@ -58,12 +62,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,6 +76,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.Font17SP
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
@@ -206,90 +211,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 +277,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 +316,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 +415,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 +434,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
width = Font17SP,
height = Font17SP,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
@@ -439,13 +477,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 +513,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 +541,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)
}
}
}
}
@@ -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,12 @@
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 java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -32,17 +30,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() == "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 +55,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,6 @@ val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Search,
Route.Notification
)
@@ -12,7 +12,6 @@ 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.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -42,7 +41,6 @@ fun AppNavigation(
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
@@ -111,7 +109,6 @@ fun AppNavigation(
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
@@ -158,7 +154,7 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
FollowList(
followLists,
list,
false
true
) { listName ->
accountViewModel.account.changeDefaultHomeFollowList(listName)
}
@@ -194,14 +190,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 +210,6 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
) {
content(accountViewModel)
}
RelayStatus(
{ wantsToEditRelays = true }
)
}
}
},
@@ -237,7 +221,9 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
},
actions = {
RelayIcon { wantsToEditRelays = true }
SearchIcon() {
nav(Route.Search.route)
}
}
)
Divider(thickness = 0.25.dp)
@@ -245,80 +231,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 +454,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(
@@ -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.
}
}
}
}
}
}
@@ -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,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -27,7 +28,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
@@ -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
)
}
}
@@ -1,7 +1,8 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -38,34 +38,49 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@Composable
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { multiSetCard.note }
@@ -90,7 +105,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
}
@@ -117,32 +134,39 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
Column(modifier = columnModifier) {
Galeries(multiSetCard, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
}
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
Row(remember { Modifier.fillMaxWidth() }) {
Spacer(modifier = remember { Modifier.width(65.dp) })
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
val (value, elapsed) = measureTimedValue {
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun Galeries(
multiSetCard: MultiSetCard,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
@@ -153,17 +177,26 @@ private fun Galeries(
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
if (hasZapEvents) {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
if (hasBoostEvents) {
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
RenderBoostGallery(boostEvents, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
if (hasLikeEvents) {
likeEvents.forEach {
RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
likeEvents.forEach {
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
}
}
Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
}
@@ -171,39 +204,23 @@ private fun Galeries(
fun RenderLikeGallery(
reactionType: String,
likeEvents: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val isNotEmpty = remember(likeEvents) {
likeEvents.isNotEmpty()
}
val shortReaction by remember {
derivedStateOf {
reactionType.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 +228,7 @@ fun RenderLikeGallery(
}
}
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
AuthorGallery(likeEvents, nav, accountViewModel)
}
}
}
@@ -233,21 +250,17 @@ fun RenderZapGallery(
@Composable
private fun ZapIcon() {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(0.dp)
}
modifier = WidthAuthorPictureModifier
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = remember {
Modifier
.size(25.dp)
.align(Alignment.TopEnd)
}
modifier =
Modifier
.size(Size25dp)
.align(Alignment.TopEnd)
)
}
}
@@ -255,21 +268,14 @@ private fun ZapIcon() {
@Composable
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(
modifier = remember {
Modifier.fillMaxWidth()
}
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(end = 4.dp)
}
modifier = NotificationIconModifierSmaller
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
@@ -283,7 +289,7 @@ fun RenderBoostGallery(
)
}
AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel)
AuthorGallery(boostEvents, nav, accountViewModel)
}
}
@@ -295,15 +301,27 @@ fun AuthorGalleryZaps(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach {
AuthorPictureAndComment(it.request, it.response, backgroundColor, nav, accountViewModel)
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
}
}
}
}
@Composable
private fun ParseAuthorCommentAndAmount(
zap: CombinedZap,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
RenderState(state, backgroundColor, accountViewModel, nav)
}
}
@Immutable
data class ZapAmountCommentNotification(
val user: User?,
@@ -312,12 +330,11 @@ data class ZapAmountCommentNotification(
)
@Composable
private fun AuthorPictureAndComment(
private fun ParseAuthorCommentAndAmount(
zapRequest: Note,
zapEvent: Note?,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
onReady: @Composable (MutableState<ZapAmountCommentNotification>) -> Unit
) {
val content = remember {
mutableStateOf(
@@ -330,29 +347,44 @@ private fun AuthorPictureAndComment(
}
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.Default) {
launch(Dispatchers.IO) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content.value = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
val newState = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
content.value = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
val newState = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
}
}
}
}
}
onReady(content)
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(
modifier = Modifier.clickable {
nav("User/${content.value.user?.pubkeyHex}")
content.value.user?.let {
nav(routeFor(it))
}
},
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(
DisplayAuthorCommentAndAmount(
authorComment = content,
backgroundColor = backgroundColor,
nav = nav,
@@ -362,91 +394,88 @@ private fun AuthorPictureAndComment(
}
val amountBoxModifier = Modifier
.fillMaxSize()
.size(Size35dp)
.clip(shape = CircleShape)
val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth()
val simpleModifier = Modifier
val sizedModifier = Modifier.size(Size35dp)
val textBoxModifier = Modifier
.padding(start = 5.dp)
.fillMaxWidth()
val bottomPadding1dp = Modifier.padding(bottom = 1.dp)
val commentTextSize = 12.sp
@Composable
private fun AuthorPictureAndComment(
private fun DisplayAuthorCommentAndAmount(
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
DisplayPicture(authorComment, accountViewModel)
DisplayAmount(authorComment)
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
CrossfadeToDisplayPicture(authorComment, accountViewModel)
CrossfadeToDisplayAmount(authorComment)
}
DisplayComment(authorComment, backgroundColor, nav, accountViewModel)
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
}
@Composable
fun DisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
authorComment.value.user?.let {
FastNoteAuthorPicture(
author = it,
size = Size35dp,
accountViewModel = accountViewModel,
pictureModifier = simpleModifier
)
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
Crossfade(authorComment.value) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
}
}
@Composable
fun DisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
authorComment.value.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
Crossfade(authorComment.value, modifier = amountBoxModifier) {
it.amount?.let {
Box(
modifier = remember {
Modifier
.fillMaxWidth()
.drawBehind { drawRect(backgroundColor) }
},
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.drawBehind { drawRect(backgroundColor) }
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
}
}
}
@Composable
fun DisplayComment(
fun CrossfadeToDisplayComment(
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
authorComment.value.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
Crossfade(authorComment.value) {
it.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -454,14 +483,13 @@ fun DisplayComment(
@Composable
fun AuthorGallery(
authorNotes: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach { note ->
BoxedAuthor(note, backgroundColor, nav, accountViewModel)
BoxedAuthor(note, nav, accountViewModel)
}
}
}
@@ -470,100 +498,78 @@ fun AuthorGallery(
@Composable
private fun BoxedAuthor(
note: Note,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(sizedModifier) {
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
}
}
@Composable
private fun NotePictureAndComment(
baseNote: Note,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val author = remember(baseNote) {
mutableStateOf(
ZapAmountCommentNotification(
user = baseNote.author,
comment = null,
amount = null
)
)
}
val modifier = remember(baseNote) {
Modifier.clickable {
nav("User/${baseNote.author?.pubkeyHex}")
}
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel)
}
}
@Composable
fun FastNoteAuthorPicture(
author: User,
size: Dp,
pictureModifier: Modifier = Modifier,
accountViewModel: AccountViewModel
) {
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,
@@ -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) {
@@ -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),
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
@@ -163,6 +163,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 +214,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 +272,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")
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -20,13 +22,11 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.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
@@ -36,38 +36,34 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
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)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val bestUserName by remember(userState) {
derivedStateOf {
userState?.user?.bestUsername()
}
}
val bestDisplayName by remember(userState) {
derivedStateOf {
userState?.user?.bestDisplayName()
}
}
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
}
}
val tags by remember(userState) {
derivedStateOf {
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
val userMetadata by baseUser.live().metadata.map {
it.user.info
}.observeAsState(baseUser.info)
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight)
} else {
NPubDisplay(npubDisplay, weight)
}
}
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@Composable
@@ -106,16 +102,18 @@ private fun UserDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
@Composable
@@ -125,22 +123,24 @@ private fun UserAndUsernameDisplay(
bestUserName: String,
modifier: Modifier
) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1
)
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1
)
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
@Composable
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -106,7 +107,7 @@ private fun RenderZapNote(
},
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
Column(
modifier = remember {
@@ -1,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")
}
}
}
@@ -22,7 +22,6 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeLiveActivitiesFeedFilter
@@ -59,13 +58,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 {
@@ -81,11 +73,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 +193,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 +223,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)
}
@@ -21,6 +21,9 @@ import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -57,6 +60,11 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun isNoteHidden(note: Note): Boolean {
val isSensitive = note.event?.isSensitive() ?: false
return account.isHidden(note.author!!) || (isSensitive && account.showSensitiveContent == false)
}
fun hasReactedTo(baseNote: Note, reaction: String): Boolean {
return account.hasReacted(baseNote, reaction)
}
@@ -263,6 +271,29 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.defaultZapType
}
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newCanPreview = !note.hasAnyReports()
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(true, true, persistentSetOf())
} else {
val newRelevantReports = account.getRelevantReports(note)
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
}
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -89,11 +88,12 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
@@ -116,7 +116,7 @@ fun ChannelScreen(
) {
if (channelId == null) return
var channelBase by remember { mutableStateOf<Channel?>(null) }
var channelBase by remember { mutableStateOf<Channel?>(LocalCache.getChannelIfExists(channelId)) }
LaunchedEffect(channelId) {
withContext(Dispatchers.IO) {
@@ -518,7 +518,7 @@ fun ChannelHeader(
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) {
@@ -549,40 +549,65 @@ fun ChannelHeader(
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()
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
VideoView(
videoUri = streamingUrl!!,
description = null
ZoomableContentView(
content = streamingUrl!!
)
}
}
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)
)
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
/*
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
*/
Column(
modifier = Modifier
@@ -592,7 +617,7 @@ fun ChannelHeader(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
channel.toBestDisplayName(),
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -608,7 +633,7 @@ fun ChannelHeader(
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
@@ -619,7 +644,7 @@ fun ChannelHeader(
Row(
modifier = Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
@@ -701,9 +726,9 @@ 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 {
@@ -715,6 +740,51 @@ private fun LiveFlag() {
)
}
@Composable
fun EndedFlag() {
Text(
text = stringResource(id = R.string.live_stream_ended_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun OfflineFlag() {
Text(
text = stringResource(id = R.string.live_stream_offline_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun ScheduledFlag() {
Text(
text = stringResource(id = R.string.live_stream_planned_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
private fun NoteCopyButton(
note: Channel
@@ -71,8 +71,6 @@ fun ChatroomListScreen(
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@@ -34,8 +34,8 @@ import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.material.MaterialRichText
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.richTextDefaults
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
@@ -86,8 +86,8 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta
Column(
modifier = Modifier.padding(30.dp)
) {
val myMarkDownStyle = richTextDefaults.copy(
stringStyle = richTextDefaults.stringStyle?.copy(
val myMarkDownStyle = RichTextDefaults.copy(
stringStyle = RichTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -85,8 +86,10 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
}
@Composable
fun HashtagHeader(tag: String, account: AccountViewModel) {
Column() {
fun HashtagHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = { }) {
Column(
Modifier.clickable { onClick() }
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
@@ -36,8 +36,8 @@ import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.dal.checkIfOnline
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -76,6 +76,8 @@ fun HomeScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
liveActivitiesViewModel.invalidateData(true)
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrHomeDataSource.invalidateFilters()
@@ -187,13 +189,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 +210,20 @@ private fun FeedLoaded(
}
@Composable
fun CheckIfOnline(note: Note, whenOnline: @Composable () -> Unit) {
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
val noteState by note.live().metadata.observeAsState()
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
online = checkIfOnline((note.event as? LiveActivitiesEvent)?.streaming())
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
}
}
if (online) {
whenOnline()
Crossfade(targetState = online) {
if (it) {
whenOnline()
}
}
}
@@ -38,13 +38,11 @@ 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.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -95,11 +93,6 @@ 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)
@@ -140,9 +133,6 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
}
Route.Search.base -> {
searchFeedViewModel.sendToTop()
}
Route.Video.base -> {
videoFeedViewModel.sendToTop()
}
@@ -193,7 +183,6 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
liveActivitiesViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,
videoFeedViewModel,
notifFeedViewModel,
userReactionsStatsModel,
@@ -76,9 +76,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 +101,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 +112,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 +162,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 +253,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 +297,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 = 8,
state = pagerState,
modifier = pagerModifier
) { page ->
CreateAndRenderPages(
page,
baseUser,
threadsViewModel,
repliesViewModel,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav
)
}
}
}
@@ -302,21 +409,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 +447,7 @@ private fun CreateAndRenderTabs(
{ FollowersTabHeader(baseUser) },
{ ZapTabHeader(baseUser) },
{ BookmarkTabHeader(baseUser) },
{ FollowedTagsTabHeader(baseUser) },
{ ReportsTabHeader(baseUser) },
{ RelaysTabHeader(baseUser) }
)
@@ -368,8 +481,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 +492,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()
@@ -1126,9 +1257,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 +1278,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 +1299,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 +1410,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()) {
@@ -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
@@ -55,14 +60,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 +82,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)
@@ -5,6 +5,7 @@ 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
@@ -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,62 @@ val LightImageModifier = Modifier
QuoteBorder
)
val MarkDownStyleOnDark = richTextDefaults.copy(
val DarkProfile35dpModifier = Modifier
.size(Size35dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(DarkColorPalette.background) }
val LightProfile35dpModifier = Modifier
.fillMaxWidth()
.clip(shape = CircleShape)
.drawBehind { drawRect(LightColorPalette.background) }
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
@@ -142,7 +193,7 @@ val MarkDownStyleOnDark = richTextDefaults.copy(
drawRect(DarkColorPalette.onSurface.copy(alpha = 0.05f))
}
),
stringStyle = richTextDefaults.stringStyle?.copy(
stringStyle = RichTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
color = DarkColorPalette.primary
),
@@ -154,10 +205,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
@@ -175,7 +226,7 @@ val MarkDownStyleOnLight = richTextDefaults.copy(
drawRect(LightColorPalette.onSurface.copy(alpha = 0.05f))
}
),
stringStyle = richTextDefaults.stringStyle?.copy(
stringStyle = RichTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
color = LightColorPalette.primary
),
@@ -243,6 +294,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
}
}
+34 -27
View File
@@ -1,30 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="192.62dp"
android:height="288.93dp"
android:viewportWidth="192.62"
android:viewportHeight="288.93">
<group>
<clip-path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73l22.81,21.62 96.31,-91.31l0,-106.32z"/>
<path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73L96.31,288.93 192.62,197.63L192.62,91.3L96.31,-0"
android:strokeWidth="0.04"
android:strokeColor="#00000000"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startX="33.75"
android:startY="252.82"
android:endX="158.87"
android:endY="36.12"
android:tileMode="clamp"
android:type="linear">
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="1" android:color="#FFA75CCC"/>
</gradient>
</aapt:attr>
</path>
</group>
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M242.66,157.32a15.48,15.75 90,1 0,31.5 0a15.48,15.75 90,1 0,-31.5 0z"
android:strokeWidth="2.74">
<aapt:attr name="android:fillColor">
<gradient
android:startX="42.27"
android:startY="20.13"
android:endX="55.73"
android:endY="20.13"
android:type="linear">
<item android:offset="0" android:color="#FF652D80"/>
<item android:offset="1" android:color="#FF2598CF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M489.48,391.59C488.93,389.92 360.91,85.09 325.7,0.54c-42.87,0 -133.4,0 -197.26,0 -18.74,46.35 -67.12,164.49 -105.93,259.66 21.27,50.74 40.37,96.32 55.1,131.48l80.32,0a5.49,5.49 0,0 0,5.13 -6.45c-0.69,-16.22 -44.84,-75.17 -31.67,-138.94 3.51,-13.72 6.2,-27.66 10.04,-41.33a482.36,482.36 135,0 1,36.88 -95.5c2.44,-4.64 3.57,-6.72 9.19,-3.21 11.99,7.46 25.38,7.03 38.72,6.06 11.5,-2.5 17.1,-8.01 38.17,-2.41 4.17,0 8.23,0 12.49,0.38 10.13,0.8 19.62,3.21 25.71,12.38 6.64,10.02 7.71,21.35 6.64,32.93 -1.62,17.32 -0.47,33.45 14.19,45.66a157.85,157.85 0,0 0,16.47 10.98c7.27,4.67 16.05,6.7 22.28,13.17 3.68,3.81 5.85,7.88 4.25,13.31 -1.59,5.43 -5.85,7.99 -11.44,8.59 -14.16,1.54 -27.99,-1.34 -41.9,-3.02 -1.81,-0.22 -3.59,-0.36 -5.49,-0.47a31.48,31.48 0,0 1,-10.46 0.36l-3.27,0a73.27,73.27 0,0 0,-16.19 3.87c-13.12,4.78 -25.06,10.04 -40.53,9.77a11.86,11.86 0,0 0,-5.63 2.44c-12.13,10.78 -19.43,24.4 -13.36,44.29 16.63,46.46 59.3,158.54 77.64,206.92 -0.52,-32.77 -0.66,-91.44 -0.77,-119.92 73.22,0.11 185.48,0.05 194.43,0.05z"
android:strokeWidth="2.74">
<aapt:attr name="android:fillColor">
<gradient
android:startX="22.6"
android:startY="255.92"
android:endX="489.54"
android:endY="255.92"
android:type="linear">
<item android:offset="0" android:color="#FF652D80"/>
<item android:offset="1" android:color="#FF2598CF"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,7 +1,15 @@
<vector android:height="108dp" android:viewportHeight="216"
android:viewportWidth="216" android:width="108dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000"
android:pathData="M90.2,73.6l-17.2,16.6 0,19.7 0,19.7 3,2.9c1.6,1.6 3.3,2.6 3.6,2.2 0.4,-0.4 1,-4.2 1.5,-8.4 0.9,-9.6 4.5,-25.4 6.9,-30.8 0.5,-1.1 1.9,-4.4 3.1,-7.3 1.9,-4.6 2.4,-5.2 4.3,-4.5 1.1,0.5 5.5,0.7 9.6,0.5 10.8,-0.3 13.7,0.3 15.5,3.8 0.8,1.6 1.5,5 1.5,7.5 0,6.1 1,7.7 7,11.4 8,4.9 5.6,8.7 -4.4,6.8 -3.9,-0.7 -6.2,-0.5 -9.6,0.6 -2.5,0.9 -5.8,1.8 -7.3,2.2 -4.4,0.9 -5.7,5.3 -5.7,19.8 0,6.8 -0.5,13.9 -1.1,15.7 -1.1,3 -1,3.4 2.9,7.3l4.1,4.1 5.8,-5.4c3.2,-3 11.3,-10.7 18.1,-17.2l12.2,-11.9 0,-18.9 0,-19 -13.2,-12.8c-14.2,-13.7 -22.3,-21.2 -22.9,-21.2 -0.2,-0 -8.2,7.5 -17.7,16.6z" android:strokeColor="#00000000"/>
<path android:fillColor="#000000"
android:pathData="M111,91.5c0,3.1 2.6,4.3 4.6,2.3 2,-2 0.7,-4.8 -2.2,-4.8 -1.9,-0 -2.4,0.5 -2.4,2.5z" android:strokeColor="#00000000"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#000000"
android:pathData="M249.48,212.29a7.57,7.7 90,1 0,15.41 0a7.57,7.7 90,1 0,-15.41 0z"
android:strokeWidth="1.34">
</path>
<path android:fillColor="#000000"
android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"
android:strokeWidth="1.34">
</path>
</vector>
File diff suppressed because one or more lines are too long
@@ -1,35 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="192.62"
android:viewportHeight="288.93">
<group android:scaleX="0.33333334"
android:scaleY="0.5"
android:translateX="64.206665"
android:translateY="72.2325">
<group>
<clip-path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73l22.81,21.62 96.31,-91.31l0,-106.32z"/>
<path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73L96.31,288.93 192.62,197.63L192.62,91.3L96.31,-0"
android:strokeWidth="0.04"
android:strokeColor="#00000000"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startX="33.75"
android:startY="252.82"
android:endX="158.87"
android:endY="36.12"
android:tileMode="clamp"
android:type="linear">
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="1" android:color="#FFA75CCC"/>
</gradient>
</aapt:attr>
</path>
</group>
</group>
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M249.48,212.29a7.57,7.7 90,1 0,15.41 0a7.57,7.7 90,1 0,-15.41 0z"
android:strokeWidth="1.34">
<aapt:attr name="android:fillColor">
<gradient
android:startX="151.49"
android:startY="145.21"
android:endX="158.07"
android:endY="145.21"
android:type="linear">
<item android:offset="0" android:color="#FF652D80"/>
<item android:offset="1" android:color="#FF2598CF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"
android:strokeWidth="1.34">
<aapt:attr name="android:fillColor">
<gradient
android:startX="141.87"
android:startY="260.51"
android:endX="370.2"
android:endY="260.51"
android:type="linear">
<item android:offset="0" android:color="#FF652D80"/>
<item android:offset="1" android:color="#FF2598CF"/>
</gradient>
</aapt:attr>
</path>
</vector>
+31
View File
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="600dp"
android:height="600dp"
android:viewportWidth="600"
android:viewportHeight="600">
<path
android:pathData="m171.93,82.63 l405.17,-82.63 0,112.99 -405.17,82.63z"
android:fillColor="#712F8F"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="M171.93,82.54l65.23,0l0,422.37l-65.23,0z"
android:fillColor="#712F8F"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m235.04,488.2c11.66,43.5 -25.91,91.36 -83.91,106.9 -58,15.54 -114.46,-7.12 -126.12,-50.62 -11.66,-43.5 25.91,-91.36 83.91,-106.9 58,-15.54 114.46,7.12 126.12,50.62z"
android:fillColor="#712F8F"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="M511.87,13.54l65.23,0l0,422.37l-65.23,0z"
android:fillColor="#712F8F"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m574.98,419.21c11.66,43.5 -25.91,91.36 -83.91,106.9 -58,15.54 -114.46,-7.12 -126.12,-50.62 -11.66,-43.5 25.91,-91.36 83.91,-106.9 58,-15.54 114.46,7.12 126.12,50.62z"
android:fillColor="#712F8F"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
</vector>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/amethyst_monochrome"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+55
View File
@@ -323,6 +323,9 @@
<string name="upload_server_nostrfilesdev">nostrfiles.dev důvěryhodný</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev může upravit soubor</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - důvěryhodný</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me může upravit soubor</string>
<string name="upload_server_imgur_nip94">Ověřitelný Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Ověří, zda Imgur upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
@@ -335,6 +338,9 @@
<string name="upload_server_nostrfilesdev_nip94">Ověřitelný Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Ověří, zda Nostrfiles.dev upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrcheckme_nip94">Ověřitelný Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Ověří, zda Nostrcheck.me upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_relays_nip95">Vaše relé (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Soubory jsou hostovány vašimi relé. Nový NIP: zkontrolujte, zda je podporují</string>
@@ -387,4 +393,53 @@
<string name="content_warning_hide_all_sensitive_content">Vždy skrýt citlivý obsah</string>
<string name="content_warning_show_all_sensitive_content">Vždy zobrazit citlivý obsah</string>
<string name="content_warning_see_warnings">Vždy zobrazovat upozornění na obsah</string>
<string name="recommended_apps">Doporučuje: </string>
<string name="filter_spam_from_strangers">Filtrovat spam od cizinců</string>
<string name="warn_when_posts_have_reports_from_your_follows">Varovat, když příspěvky obsahují hlášení od vašich sledovaných osob</string>
<string name="new_reaction_symbol">Nový symbol reakce</string>
<string name="no_reaction_type_setup_long_press_to_change">Nebyly vybrány žádné typy reakcí. Dlouhým stiskem změňte</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Přidá cílovou částku sats pro tento příspěvek. Podporované klienty ji mohou zobrazovat jako ukazatel postupu k podněcování darování</string>
<string name="zapraiser_target_amount_in_sats">Cílová částka v sats</string>
<string name="sats_to_complete">Zapraiser na %1$s. Do cíle zbývá %2$s sats</string>
<string name="read_from_relay">Číst z Relay</string>
<string name="write_to_relay">Zapisovat do Relay</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Při pokusu o získání informací z Relay se vyskytla chyba z %1$s</string>
<string name="owner">Vlastník</string>
<string name="version">Verze</string>
<string name="software">Software</string>
<string name="contact">Kontakt</string>
<string name="supports">Podporované NIPs</string>
<string name="admission_fees">Vstupné</string>
<string name="payments_url">URL plateb</string>
<string name="limitations">Omezení</string>
<string name="countries">Země</string>
<string name="languages">Jazyky</string>
<string name="tags">Tagy</string>
<string name="posting_policy">Politika příspěvků</string>
<string name="message_length">Délka zprávy</string>
<string name="subscriptions">Předplatné</string>
<string name="filters">Filtry</string>
<string name="subscription_id_length">Délka ID předplatného</string>
<string name="minimum_prefix">Minimální předpona</string>
<string name="maximum_event_tags">Maximální počet značek události</string>
<string name="content_length">Délka obsahu</string>
<string name="minimum_pow">Minimální PoW</string>
<string name="auth">Ověření</string>
<string name="payment">Platba</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Vyměnit</string>
<string name="live_stream_live_tag">ŽIVĚ</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">UKONČENO</string>
<string name="live_stream_planned_tag">PLÁNOVÁNO</string>
<string name="live_stream_is_offline">Živé vysílání je offline</string>
<string name="live_stream_has_ended">Živé vysílání ukončeno</string>
<string name="are_you_sure_you_want_to_log_out">Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat?</string>
</resources>
+46
View File
@@ -330,6 +330,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - vertrauenswürdig</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kann die Datei ändern</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - vertrauenswürdig</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me kann die Datei ändern</string>
<string name="upload_server_imgur_nip94">Verifizierbares Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Überprüft, ob Imgur die Datei geändert hat. Neuer NIP: Andere Clients sehen es möglicherweise nicht</string>
@@ -343,6 +345,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="upload_server_nostrfilesdev_nip94">Verifizierbares Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Überprüft, ob Nostrfiles.dev die Datei geändert hat. Neuer NIP: Andere Clients sehen es möglicherweise nicht</string>
<string name="upload_server_nostrcheckme_nip94">Verifizierbares Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Überprüft, ob Nostrcheck.me die Datei geändert hat. Neuer NIP: Andere Clients sehen es möglicherweise nicht</string>
<string name="upload_server_relays_nip95">Deine Relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Dateien werden von deinen Relays gehostet. Neuer NIP: Überprüfe, ob sie unterstützt werden</string>
@@ -400,9 +404,51 @@ anz der Bedingungen ist erforderlich</string>
<string name="recommended_apps">Empfohlene Apps: </string>
<string name="filter_spam_from_strangers">Spam von Fremden filtern</string>
<string name="warn_when_posts_have_reports_from_your_follows">Warnung bei Meldungen von deinen Abonnements</string>
<string name="new_reaction_symbol">Neues Reaktionssymbol</string>
<string name="no_reaction_type_setup_long_press_to_change">Keine Reaktionstypen ausgewählt. Lange drücken, um zu ändern</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Fügt einen Zielbetrag in Sats für diesen Beitrag hinzu. Unterstützende Clients können dies als Fortschrittsbalken anzeigen, um Spenden zu fördern</string>
<string name="zapraiser_target_amount_in_sats">Zielbetrag in Sats</string>
<string name="sats_to_complete">Zapraiser bei %1$s. %2$s Sats bis zum Ziel</string>
<string name="read_from_relay">Von Relay lesen</string>
<string name="write_to_relay">In Relay schreiben</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten</string>
<string name="owner">Inhaber</string>
<string name="version">Version</string>
<string name="software">Software</string>
<string name="contact">Kontakt</string>
<string name="supports">Unterstützte NIPs</string>
<string name="admission_fees">Eintrittsgebühren</string>
<string name="payments_url">Zahlungs-URL</string>
<string name="limitations">Einschränkungen</string>
<string name="countries">Länder</string>
<string name="languages">Sprachen</string>
<string name="tags">Tags</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="message_length">Nachrichtenlänge</string>
<string name="subscriptions">Abonnements</string>
<string name="filters">Filter</string>
<string name="subscription_id_length">Länge der Abonnement-ID</string>
<string name="minimum_prefix">Mindestpräfix</string>
<string name="maximum_event_tags">Maximale Anzahl von Ereignis-Tags</string>
<string name="content_length">Inhaltslänge</string>
<string name="minimum_pow">Mindest-PoW</string>
<string name="auth">Authentifizierung</string>
<string name="payment">Zahlung</string>
<string name="cashu">Cashu-Token</string>
<string name="cashu_redeem">Einlösen</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">BEENDET</string>
<string name="live_stream_planned_tag">GEPLANT</string>
<string name="live_stream_is_offline">Livestream ist offline</string>
<string name="live_stream_has_ended">Livestream beendet</string>
<string name="are_you_sure_you_want_to_log_out">Das Abmelden löscht alle Ihre lokalen Informationen. Stellen Sie sicher, dass Sie Ihre privaten Schlüssel gesichert haben, um einen Kontoverlust zu vermeiden. Möchten Sie fortfahren?</string>
</resources>
+35 -1
View File
@@ -391,7 +391,7 @@
<string name="content_warning_hide_all_sensitive_content">Az érzékeny tartalmat mindig rejtse el</string>
<string name="content_warning_show_all_sensitive_content">Az érzékeny tartalmat mindig jelenítse meg</string>
<string name="content_warning_see_warnings">A tartalomra vonatkozó figyelmeztetéseket mindig jelenítse meg</string>
<string name="recommended_apps">Ajánlottak: </string>
<string name="filter_spam_from_strangers">Idegenektől SPAM szűrése</string>
<string name="warn_when_posts_have_reports_from_your_follows">Figyelmeztessen ha a bejegyzések azok által akiket követek jelentve vannak</string>
@@ -406,4 +406,38 @@
<string name="sats_to_complete">A ZapGyűjtés %1$s-nál. %2$s sats kell a célig</string>
<string name="read_from_relay">Olvassás a csomópontból</string>
<string name="write_to_relay">Írás a csomópontra</string>
<string name="an_error_ocurred_trying_to_get_relay_information">Hiba történt a csomópont információ szerzés közben a %1$s -tól</string>
<string name="owner">Tulajdonos</string>
<string name="version">Verzió</string>
<string name="software">Szoftver</string>
<string name="contact">Kapcsolat</string>
<string name="supports">Támogatott NIP-ek</string>
<string name="admission_fees">Csatlakozás díja</string>
<string name="payments_url">Fizetési cím</string>
<string name="limitations">Korlátok</string>
<string name="countries">Országok</string>
<string name="languages">Nyelvek</string>
<string name="tags">Címkék</string>
<string name="posting_policy">Szabályzat</string>
<string name="message_length">Üzenet hossza</string>
<string name="subscriptions">Feliratkozások</string>
<string name="filters">Filterek</string>
<string name="subscription_id_length">Feliratkozás azonosítójának hossza</string>
<string name="minimum_prefix">Minimális előtag</string>
<string name="maximum_event_tags">Maximális eseménycímkék</string>
<string name="content_length">Tartalom hossza</string>
<string name="minimum_pow">Minimális PoW</string>
<string name="auth">Azonosítás</string>
<string name="payment">Fizetés</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Beváltás</string>
<string name="live_stream_live_tag">ÉLŐ</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">VÉGE</string>
<string name="live_stream_planned_tag">ÜTEMEZETT</string>
<string name="live_stream_is_offline">Az élő közvetítés offline</string>
<string name="live_stream_has_ended">Az élő közvetítésnek vége</string>
<string name="are_you_sure_you_want_to_log_out">A Kijelentkezés minden helyben tárolt információt töröl. Bizonyosodj meg róla, hogy a privát kulcsod le van mentve, ezzel elkerülve a fiókod elvesztését. Folytatni akarod?</string>
</resources>
+2 -1
View File
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\
</style>
</resources>

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