Compare commits

..
142 Commits
Author SHA1 Message Date
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
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
Vitor Pamplona b4395e7585 v0.62.2 2023-06-23 18:10:25 -04:00
Vitor Pamplona f8acb26763 Only showing the paid relay icon when the relay is paid. 2023-06-23 18:09:03 -04:00
Vitor Pamplona aa931a4cc5 Improves Logs when the app cannot connect with relays. 2023-06-23 18:02:04 -04:00
Vitor Pamplona e1cf188fbe Displays an error message when it cannot parse the returning json from relays. 2023-06-23 17:57:21 -04:00
Vitor Pamplona 9e57a706ae Moves some of the account observables to maps 2023-06-23 17:57:02 -04:00
Vitor Pamplona 8c73e981b5 formatting 2023-06-23 17:56:25 -04:00
Vitor Pamplona 87f5007fd3 Displays error message on LogCat if fails to login 2023-06-23 17:56:13 -04:00
Vitor Pamplona 5c42bb0282 Removes check in the Main Thread to allow login on Debug 2023-06-23 17:55:51 -04:00
Vitor Pamplona 415252b770 Converts TopBar Observables to Maps. 2023-06-23 17:55:30 -04:00
Vitor Pamplona 4f43d7cde5 More updates to the readme 2023-06-23 15:22:09 -04:00
Vitor Pamplona c819a23f83 updating Readme 2023-06-23 15:16:57 -04:00
Vitor Pamplona 4a00a9a80d v0.62.1 2023-06-23 15:11:08 -04:00
Vitor Pamplona 1f5f6b251c BugFix on publishedAt date for Longform posts 2023-06-23 15:08:26 -04:00
Vitor Pamplona c925b82e86 Stops showing posts in the future 2023-06-23 14:30:26 -04:00
Vitor Pamplona 96a5d1a658 v0.62.0 2023-06-23 14:11:56 -04:00
Vitor Pamplona 36154422f1 Formatting 2023-06-23 13:41:04 -04:00
Vitor Pamplona 5b2a878f5d Moving the NIP98 event to it's own class. 2023-06-23 13:40:57 -04:00
Vitor Pamplona 976d96ef35 formatting 2023-06-23 12:27:08 -04:00
Vitor PamplonaandGitHub 94b31fe5f9 Merge pull request #466 from believethehype/main
added NIP98 Support, Added nostrcheck.me Image hoster
2023-06-23 12:26:51 -04:00
Vitor PamplonaandGitHub cd2a59ae43 Merge pull request #465 from greenart7c3/main
show icon for paid relays in the relay list
2023-06-23 12:18:23 -04:00
Vitor Pamplona e8f5ef3aa3 Removes check in the main thread to avoid mocking Android's threading system 2023-06-23 12:10:46 -04:00
Vitor Pamplona 887fc33073 Minor performance adjustments across the app. 2023-06-23 12:02:27 -04:00
Vitor Pamplona c24ba18207 Avoids calling out errors in LogCat when they don't exist. 2023-06-23 12:01:46 -04:00
Vitor Pamplona 5137009f5f Improves rendering time of the Channel header 2023-06-23 11:36:50 -04:00
Vitor Pamplona ba6bd6a2d2 Making more classes stable for performance reasons. 2023-06-23 11:36:08 -04:00
Believethehype bfc16d0878 added some wait time before updating status 2023-06-23 16:39:40 +02:00
Believethehype 94c41e0c6d added NIP98 Support, Added nostrcheck.me hoster 2023-06-23 16:10:04 +02:00
Vitor Pamplona 33454cc2f8 Fixes UTF-32 Reactions 2023-06-23 09:21:34 -04:00
Believethehype d969a483a9 Merge remote-tracking branch 'upstream/main' 2023-06-23 12:50:22 +02:00
greenart7c3 a51104fd82 show icon for paid relays in the relay list 2023-06-23 06:51:21 -03:00
Vitor Pamplona a536388123 Avoids crashing due to illegal zap address 2023-06-22 19:39:55 -04:00
Vitor Pamplona c7cb4b7015 Adds logs to Init functions of classes to keep a close eye on. 2023-06-22 17:41:55 -04:00
Vitor Pamplona fbb1b59e34 Adds checks for the main thread when parsing NIP30 2023-06-22 17:41:11 -04:00
Vitor Pamplona 7d7f047f55 removes unused variable 2023-06-22 15:23:16 -04:00
Vitor Pamplona e9e0b0f57f Adds more checks for not in the main thread 2023-06-22 15:23:04 -04:00
Vitor Pamplona baf0372537 Moves the image border modifier to an app variable. 2023-06-22 15:16:17 -04:00
Vitor Pamplona f62716a226 formatting 2023-06-22 15:11:55 -04:00
Vitor Pamplona 9b1c47f4a5 Checks main thread in preferences 2023-06-22 14:53:10 -04:00
Vitor Pamplona a4dd3aa488 Moves checks to run to outside the LaunchedEffect 2023-06-22 14:30:22 -04:00
Vitor Pamplona 9dbe7379cd Fixes reaction row space. 2023-06-22 14:25:18 -04:00
Vitor Pamplona ad353d2d1a Generalizes the SensitivityWarning to Note. 2023-06-22 12:04:11 -04:00
Vitor Pamplona 32b2b1ace8 Uses derivedOf to protect from updates with the same content 2023-06-22 12:03:52 -04:00
Vitor Pamplona 13710dde06 No need to go into a Synchronized block to get a user object here. 2023-06-22 11:49:49 -04:00
Vitor Pamplona 915ddd0bad Improving relay options rendering performance. 2023-06-22 11:49:27 -04:00
Vitor Pamplona 52ef356c06 Minor performance improvements throughout the code 2023-06-22 11:39:56 -04:00
Vitor Pamplona 69481a046b v0.61.4 2023-06-21 14:56:44 -04:00
Vitor Pamplona 157802142c Reorganizing Relay Info Page 2023-06-21 14:51:44 -04:00
Vitor PamplonaandGitHub 54462f8d26 Merge pull request #462 from greenart7c3/relay_information
Relay information dialog
2023-06-21 14:03:34 -04:00
Vitor PamplonaandGitHub fcf612d22a Merge pull request #461 from ZsZolee/main
Zapraiser Hungarian translation
2023-06-21 14:02:46 -04:00
Vitor Pamplona e8eea4be25 Removes old image proxy classes 2023-06-21 12:47:53 -04:00
Vitor Pamplona cce9d6cf68 Improves rendering performance of Chat Screens 2023-06-21 11:57:49 -04:00
greenart7c3 143f58fd68 add string resources 2023-06-21 11:38:34 -03:00
Vitor Pamplona 6fe66986be Improves rendering of chat messages when scrolling. 2023-06-21 10:23:49 -04:00
greenart7c3 68e8abecb2 fix lints 2023-06-21 11:23:06 -03:00
greenart7c3 bb698a351e fix build after rebasing 2023-06-21 10:47:27 -03:00
greenart7c3 4506ff9c93 created dialog to show relay information 2023-06-21 10:43:34 -03:00
ZoltanandGitHub f88a4d56c7 Zapraiser Hungarian translation 2023-06-21 10:12:48 +02:00
Vitor Pamplona fbe0f584af v0.61.3 2023-06-20 19:00:46 -04:00
Vitor Pamplona e28f26d471 BugFix for opening the video chat with video in full screen until it loads. 2023-06-20 18:59:21 -04:00
Vitor Pamplona 80a9a2a712 Fix margins when summary is not available. 2023-06-20 18:58:56 -04:00
Vitor Pamplona 2a47cd4fd1 BugFix for too many streams (fix in max = 2 for now) 2023-06-20 18:58:41 -04:00
Vitor Pamplona ce207aed39 Refreshing home when follows change, including Live activities. 2023-06-20 18:27:21 -04:00
Believethehype 279894f9d7 Merge remote-tracking branch 'upstream/main' 2023-06-14 22:31:07 +02:00
Believethehype ffa03e1d0d Merge remote-tracking branch 'upstream/main' 2023-05-30 09:17:15 +02:00
Believethehype 719f5c2ac6 Merge remote-tracking branch 'upstream/main' 2023-05-24 17:30:10 +02:00
Believethehype baa3a21dd2 Merge remote-tracking branch 'upstream/main' 2023-05-09 21:04:08 +02:00
Believethehype 8cb84c2395 Merge remote-tracking branch 'upstream/main' 2023-05-05 17:52:38 +02:00
Believethehype ee52e725d9 Merge remote-tracking branch 'upstream/main' 2023-05-03 00:35:41 +02:00
Believethehype f3e0c31087 Merge remote-tracking branch 'upstream/main' 2023-05-02 00:13:32 +02:00
Believethehype 92ccdfc55f Merge remote-tracking branch 'upstream/main' 2023-05-01 18:59:38 +02:00
Believethehype 0528e59572 Merge remote-tracking branch 'upstream/main' 2023-05-01 13:42:26 +02:00
Believethehype 0ab3219115 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-05-01 13:42:05 +02:00
Believethehype 724cfddb69 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-26 19:22:44 +02:00
Believethehype 7fcc28a320 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-23 18:09:28 +02:00
Believethehype 94051f178b Merge remote-tracking branch 'upstream/main' 2023-04-22 04:06:40 +02:00
Believethehype 8e4f339aa1 Merge remote-tracking branch 'upstream/main' 2023-04-21 15:21:24 +02:00
Believethehype 425aa69691 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-12 19:08:46 +02:00
Believethehype 1c65334b91 Delete footstr.xml 2023-04-09 23:39:57 +02:00
Believethehype 726d04f0f4 Update HashtagIcon.kt 2023-04-09 23:39:32 +02:00
Believethehype 9d1ab5a9b8 introduce zap types, implemented non-zaps in custom zap dialog
Non-Zaps allow to tip the author of a note without using the zap mechanism (no record on the note)
2023-04-09 23:37:23 +02:00
Believethehype 09bad79870 #footstr 2023-04-09 05:31:31 +02:00
110 changed files with 5333 additions and 2534 deletions
+8 -3
View File
@@ -19,7 +19,9 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Replies and mentions (NIP-10)
- [x] Reactions (NIP-25)
- [x] Reposts (NIP-18)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
@@ -56,6 +58,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
@@ -65,12 +68,14 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities (NIP-102)
- [x] Live Activities & Live Chats (NIP-102)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 218
versionName "0.61.2"
versionCode 228
versionName "0.62.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
object ServiceManager {
private var account: Account? = null
@@ -66,6 +67,7 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
@@ -322,6 +322,12 @@ class Account(
}
}
fun createHTTPAuthorization(url: String, method: String, body: String? = null): HTTPAuthorizationEvent? {
if (!isWriteable()) return null
return HTTPAuthorizationEvent.create(url, method, body, loggedIn.privKey!!)
}
fun boost(note: Note) {
if (!isWriteable()) return
@@ -1128,7 +1134,7 @@ class Account(
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
private fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
}
@@ -1222,8 +1228,7 @@ class Account(
GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
@@ -1234,6 +1239,7 @@ class Account(
}
init {
Log.d("Init", "Account")
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -17,7 +18,6 @@ class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
@@ -44,15 +44,9 @@ class AntiSpamFilter {
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
liveSpam.invalidateData()
} else {
val spammer = spamMessages.get(hash)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
logOffender(hash, event)
liveSpam.invalidateData()
}
liveSpam.invalidateData()
return true
}
@@ -62,9 +56,20 @@ class AntiSpamFilter {
return false
}
@Synchronized
private fun logOffender(hashCode: Int, event: Event) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
@Stable
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
@@ -33,7 +33,7 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
@@ -65,7 +65,7 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } ?: super.profilePicture()
return info?.image()?.ifBlank { null }
}
override fun anyNameStartsWith(prefix: String): Boolean {
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -12,7 +13,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -41,14 +41,12 @@ 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
}
}
@@ -83,14 +81,12 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
return notes.get(idHex) ?: run {
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
}
@@ -112,23 +108,17 @@ object LocalCache {
}
private fun isValidHexNpub(key: String): Boolean {
return try {
Hex.decode(key).toNpub()
true
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
false
}
if (key.contains(":")) return false
return HexValidator.isHex(key)
}
@Synchronized
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = channelFactory(key)
channels.put(key, answer)
answer
val newObject = channelFactory(key)
channels.putIfAbsent(key, newObject) ?: newObject
}
}
@@ -146,16 +136,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
}
}
@@ -544,8 +532,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -1280,12 +1267,13 @@ object LocalCache {
}
}
@Stable
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
@@ -30,7 +30,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
override fun createdAt(): Long? {
if (event == null) return null
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
return minOf(publishedAt, lastCreatedAt)
}
}
@Stable
@@ -490,9 +497,9 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
fun invalidateData() {
bundler.invalidate() {
// if (hasObservers()) {
postValue(NoteState(note))
// }
if (hasActiveObservers()) {
postValue(NoteState(note))
}
}
}
@@ -0,0 +1,250 @@
package com.vitorpamplona.amethyst.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
class RelayInformation(
val name: String?,
val description: String?,
val pubkey: String?,
val contact: String?,
val supported_nips: List<Int>?,
val supported_nip_extensions: List<String>?,
val software: String?,
val version: String?,
val limitation: RelayInformationLimitation?,
val relay_countries: List<String>?,
val language_tags: List<String>?,
val tags: List<String>?,
val posting_policy: String?,
val payments_url: String?,
val fees: RelayInformationFees?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
}
}
class RelayInformationFee(
val amount: Int?,
val unit: String?,
val period: Int?,
val kinds: List<Int>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
}
}
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
override fun serialize(
src: RelayInformationFee,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("amount", src.amount)
addProperty("unit", src.unit)
addProperty("period", src.period)
add(
"kinds",
JsonArray().also { kinds ->
src.kinds?.forEach { kind ->
kinds.add(
kind
)
}
}
)
}
}
}
class RelayInformationFees(
val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.create()
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
}
}
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
override fun serialize(
src: RelayInformationFees,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
add(
"admission",
JsonArray().also { admissions ->
src.admission?.forEach { admission ->
admissions.add(
admission.toString()
)
}
}
)
add(
"publication",
JsonArray().also { publications ->
src.publication?.forEach { publication ->
publications.add(
publication.toString()
)
}
}
)
add(
"subscription",
JsonArray().also { subscriptions ->
src.subscription?.forEach { subscription ->
subscriptions.add(
subscription.toString()
)
}
}
)
}
}
}
class RelayInformationLimitation(
val max_message_length: Int?,
val max_subscriptions: Int?,
val max_filters: Int?,
val max_limit: Int?,
val max_subid_length: Int?,
val min_prefix: Int?,
val max_event_tags: Int?,
val max_content_length: Int?,
val min_pow_difficulty: Int?,
val auth_required: Boolean?,
val payment_required: Boolean?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.create()
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
}
}
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
override fun serialize(
src: RelayInformationLimitation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("max_message_length", src.max_message_length)
addProperty("max_subscriptions", src.max_subscriptions)
addProperty("max_filters", src.max_filters)
addProperty("max_limit", src.max_limit)
addProperty("max_subid_length", src.max_subid_length)
addProperty("min_prefix", src.min_prefix)
addProperty("max_event_tags", src.max_event_tags)
addProperty("max_content_length", src.max_content_length)
addProperty("min_pow_difficulty", src.min_pow_difficulty)
addProperty("auth_required", src.auth_required)
addProperty("payment_required", src.payment_required)
}
}
}
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
override fun serialize(
src: RelayInformation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("name", src.name)
addProperty("description", src.description)
addProperty("pubkey", src.pubkey)
addProperty("contact", src.contact)
add(
"supported_nip_extensions",
JsonArray().also { supported_nip_extensions ->
src.supported_nip_extensions?.forEach { nip ->
supported_nip_extensions.add(
nip
)
}
}
)
add(
"supported_nips",
JsonArray().also { supported_nips ->
src.supported_nips?.forEach { nip ->
supported_nips.add(
nip
)
}
}
)
addProperty("software", src.software)
addProperty("version", src.version)
add(
"relay_countries",
JsonArray().also { relay_countries ->
src.relay_countries?.forEach { country ->
relay_countries.add(
country
)
}
}
)
add(
"language_tags",
JsonArray().also { language_tags ->
src.language_tags?.forEach { language_tag ->
language_tags.add(
language_tag
)
}
}
)
add(
"tags",
JsonArray().also { tags ->
src.tags?.forEach { tag ->
tags.add(
tag
)
}
}
)
addProperty("posting_policy", src.posting_policy)
addProperty("payments_url", src.payments_url)
}
}
}
@@ -12,5 +12,6 @@ data class RelaySetupInfo(
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
)
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
@Stable
object UrlCachedPreviewer {
var cache = LruCache<String, UrlPreviewState>(100)
private set
@@ -12,6 +12,8 @@ import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -144,8 +146,10 @@ class User(val pubkeyHex: String) {
}
fun updateAcceptedBadges(note: AddressableNote) {
acceptedBadges = note
liveSet?.badges?.invalidateData()
if (acceptedBadges?.idHex != note.idHex) {
acceptedBadges = note
liveSet?.badges?.invalidateData()
}
}
fun addZap(zapRequest: Note, zap: Note?) {
@@ -185,34 +189,34 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatroom
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
checkNotInMainThread()
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
}
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@@ -235,6 +239,7 @@ class User(val pubkeyHex: String) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
try {
@@ -371,7 +376,27 @@ data class RelayInfo(
var counter: Long
)
data class Chatroom(var roomMessages: Set<Note>)
class Chatroom() {
var roomMessages: Set<Note> = setOf()
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
}
@Stable
class UserMetadata {
@@ -399,6 +424,7 @@ class UserMetadata {
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
@@ -412,6 +438,23 @@ class UserMetadata {
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
@@ -14,6 +15,7 @@ import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri
@@ -0,0 +1,93 @@
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 mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
)
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
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(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) {
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) ?: "Error")
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
}
}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
@@ -18,7 +20,7 @@ class NIP30Parser {
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
@@ -70,6 +70,7 @@ abstract class NostrDataSource(val debugName: String) {
}
init {
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
@Immutable
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(10)
val fiveMinutes = 1000 * 60 * 5
fun isOnline(url: String?): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online
}
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
fun String.isUTF16Char(pos: Int): Boolean {
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.firstFullChar(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
else -> {
val first = isUTF16Char(0)
val second = isUTF16Char(2)
if (first && second) {
this.take(4)
} else if (first) {
this.take(2)
} else {
this.take(1)
}
}
}
}
@@ -55,28 +55,32 @@ class LightningAddressResolver() {
return
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
@@ -46,5 +46,6 @@ class ChannelCreateEvent(
}
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
@Immutable
data class ChannelData(val name: String?, val about: String?, val picture: String?)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
@@ -12,6 +13,7 @@ import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
@Stable
class ContactListEvent(
id: HexKey,
pubKey: HexKey,
@@ -55,7 +55,7 @@ open class Event(
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0].equals("zapraiser", true))
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
@@ -0,0 +1,49 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.security.MessageDigest
import java.util.Date
@Immutable
class HTTPAuthorizationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 27235
fun create(
url: String,
method: String,
body: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): HTTPAuthorizationEvent {
val sha256 = MessageDigest.getInstance("SHA-256")
var hash = ""
body?.let {
hash = sha256.digest(it.toByteArray()).toHexKey()
}
val tags = listOfNotNull(
listOf("u", url),
listOf("method", method),
listOf("payload", hash)
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return HTTPAuthorizationEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -24,7 +24,7 @@ class LongTextNoteEvent(
fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun publishedAt() = try {
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
null
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
@@ -12,9 +13,10 @@ import nostr.postr.Utils
import java.io.ByteArrayInputStream
import java.util.Date
@Stable
abstract class IdentityClaim(
var identity: String,
var proof: String
val identity: String,
val proof: String
) {
abstract fun toProofUrl(): String
abstract fun toIcon(): Int
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.HexValidator
import fr.acinq.secp256k1.Hex
import nostr.postr.Utils
import nostr.postr.toHex
@@ -23,11 +24,18 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun verifiedRecipientPubKey(): HexKey? {
val recipient = recipientPubKey()
return if (HexValidator.isHex(recipient)) {
recipient
} else {
null
}
}
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
@@ -17,8 +17,8 @@ class RepostEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun boostedPost() = taggedEvents()
fun originalAuthor() = taggedUsers()
fun containedPost() = try {
fromJson(content, Client.lenient)
@@ -67,7 +67,7 @@ object Nip19 {
}
parsed?.copy(additionalChars = additionalChars ?: "")
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
}
@@ -7,6 +7,7 @@
package com.vitorpamplona.amethyst.service.tts
import android.content.Context
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -33,6 +34,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
private var customActionForDestroy: (() -> Unit)? = null
init {
Log.d("Init", "Init TTS")
initTTS()
}
@@ -39,6 +39,7 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -7,19 +7,21 @@ import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.IOException
import okio.source
import java.io.IOException
import java.io.InputStream
import java.util.Base64
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
lateinit var account: Account
fun uploadImage(
uri: Uri,
@@ -33,12 +35,11 @@ object ImageUploader {
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
@@ -58,6 +59,9 @@ object ImageUploader {
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
NostrCheckMeServer()
}
else -> {
NostrBuildServer()
}
@@ -99,7 +103,7 @@ object ImageUploader {
)
.build()
server.clientID()?.let {
server.clientID(requestBody.toString())?.let {
requestBuilder.addHeader("Authorization", it)
}
@@ -133,6 +137,12 @@ object ImageUploader {
}
})
}
fun NIP98Header(url: String, method: String, body: String): String {
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
return "Nostr " + encodedNIP98Event
}
}
abstract class FileServer {
@@ -140,7 +150,7 @@ abstract class FileServer {
abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(): String? = null
open fun clientID(info: String): String? = null
}
class NostrImgServer : FileServer() {
@@ -156,7 +166,7 @@ class NostrImgServer : FileServer() {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null
override fun clientID(info: String) = null
}
class ImgurServer : FileServer() {
@@ -175,7 +185,7 @@ class ImgurServer : FileServer() {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = "Client-ID e6aea87296f3f96"
override fun clientID(info: String) = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
@@ -189,7 +199,7 @@ class NostrBuildServer : FileServer() {
return "fileToUpload"
}
override fun clientID() = null
override fun clientID(info: String) = null
}
class NostrFilesDevServer : FileServer() {
@@ -203,5 +213,43 @@ class NostrFilesDevServer : FileServer() {
return "file"
}
override fun clientID() = null
override fun clientID(info: String) = null
}
class NostrCheckMeServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("url")?.asText()
var id = tree?.get("id")?.asText()
var isCompleted = false
val client = OkHttpClient()
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
val request = Request.Builder()
.url(requrl)
.addHeader("Authorization", ImageUploader.NIP98Header(requrl, "GET", ""))
.get()
.build()
while (!isCompleted) {
val response = client.newCall(request).execute()
val body = response.body?.string()
val tree = jacksonObjectMapper().readTree(body)
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
return url
}
override fun inputParameterName(contentType: String?): String {
return "mediafile"
}
override fun clientID(body: String) = ImageUploader.NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body)
}
@@ -33,6 +33,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -60,10 +61,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
@@ -347,6 +349,10 @@ private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
onClick: () -> Unit
) {
val hasNewMessages = remember {
mutableStateOf(false)
}
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
@@ -358,7 +364,7 @@ private fun RenderChannel(
},
channelLastTime = null,
channelLastContent = item.summary(),
false,
hasNewMessages,
onClick = onClick
)
}
@@ -384,7 +390,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, 55.dp, accountViewModel)
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier = Modifier
@@ -50,6 +50,8 @@ open class NewMediaModel : ViewModel() {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
}
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -621,7 +622,7 @@ private fun ForwardZapTo(
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.placeholderText,
fontSize = 14.sp
fontSize = Font14SP
)
},
colors = TextFieldDefaults
@@ -797,11 +798,13 @@ enum class ServersAvailable {
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}
@@ -824,10 +827,12 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -28,6 +30,7 @@ import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SyncProblem
@@ -53,22 +56,32 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.lang.Math.round
@Composable
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "") {
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel.account)
postViewModel.relays.value.forEach { item ->
loadRelayInfo(item.url, context, scope) {
postViewModel.togglePaidRelay(item, it.limitation?.payment_required ?: false)
}
}
}
Dialog(
@@ -114,6 +127,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
if (index == 0) {
ServerConfigHeader()
}
ServerConfig(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
@@ -125,7 +139,11 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) }
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
nav = nav,
scope = scope,
context = context
)
}
}
@@ -163,7 +181,7 @@ fun ServerConfigHeader() {
Text(
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
)
@@ -173,7 +191,7 @@ fun ServerConfigHeader() {
Text(
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
)
@@ -183,7 +201,7 @@ fun ServerConfigHeader() {
Text(
text = stringResource(R.string.errors),
maxLines = 1,
fontSize = 14.sp,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
)
@@ -193,7 +211,7 @@ fun ServerConfigHeader() {
Text(
text = stringResource(R.string.spam),
maxLines = 1,
fontSize = 14.sp,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
)
@@ -222,10 +240,25 @@ fun ServerConfig(
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit
onDelete: (RelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
context: Context,
scope: CoroutineScope
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
if (relayInfo != null) {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
)
}
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -249,9 +282,26 @@ fun ServerConfig(
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(14.dp),
tint = Color.Green
)
}
Text(
text = item.url.removePrefix("wss://"),
modifier = Modifier.weight(1f),
text = item.url.removePrefix("wss://").removeSuffix("/"),
modifier = Modifier
.weight(1f)
.clickable {
loadRelayInfo(item.url, context, scope) {
relayInfo = it
}
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -275,11 +325,13 @@ fun ServerConfig(
onClick = { onToggleFollows(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.home_feed),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.home_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -306,11 +358,13 @@ fun ServerConfig(
onClick = { onTogglePrivateDMs(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.private_message_feed),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.private_message_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -337,11 +391,13 @@ fun ServerConfig(
onClick = { onTogglePublicChats(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.public_chat_feed),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.public_chat_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -368,11 +424,13 @@ fun ServerConfig(
onClick = { onToggleGlobal(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.global_feed),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.global_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -400,11 +458,13 @@ fun ServerConfig(
onClick = { onToggleSearch(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -436,11 +496,13 @@ fun ServerConfig(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -476,11 +538,13 @@ fun ServerConfig(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -512,11 +576,13 @@ fun ServerConfig(
onClick = { },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -541,11 +607,13 @@ fun ServerConfig(
onClick = { },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
).show()
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
)
.show()
}
}
),
@@ -136,6 +136,12 @@ class NewRelayListViewModel : ViewModel() {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePaidRelay(relay: RelaySetupInfo, paid: Boolean) {
_relays.update {
it.updated(relay, relay.copy(paidRelay = paid))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
@@ -0,0 +1,350 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayInfo: RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize()
.verticalScroll(scrollState)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
onClose()
})
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Title(relayInfo.name ?: "")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
SectionContent(relayInfo.description ?: "")
}
Section(stringResource(R.string.owner))
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel, nav)
}
Section(stringResource(R.string.software))
DisplaySoftwareInformation(relayInfo)
Section(stringResource(R.string.version))
SectionContent(relayInfo.version ?: "")
Section(stringResource(R.string.contact))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableEmail(relayInfo.contact ?: "")
}
Section(stringResource(R.string.supports))
DisplaySupportedNips(relayInfo)
relayInfo.fees?.admission?.let {
if (it.isNotEmpty()) {
Section(stringResource(R.string.admission_fees))
it.forEach { item ->
SectionContent("${item.amount?.div(1000) ?: 0} sats")
}
}
}
relayInfo.payments_url?.let {
Section(stringResource(R.string.payments_url))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = it,
url = it
)
}
}
relayInfo.limitation?.let {
Section(stringResource(R.string.limitations))
val authRequired = it.auth_required ?: false
val authRequiredText = if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequired = it.payment_required ?: false
val paymentRequiredText = if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no)
Column {
SectionContent("${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}")
SectionContent("${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}")
SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}")
SectionContent("${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}")
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
SectionContent("${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}")
SectionContent("${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}")
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
}
}
relayInfo.relay_countries?.let {
Section(stringResource(R.string.countries))
FlowRow {
it.forEach { item ->
SectionContent(item)
}
}
}
relayInfo.language_tags?.let {
Section(stringResource(R.string.languages))
FlowRow {
it.forEach { item ->
SectionContent(item)
}
}
}
relayInfo.tags?.let {
Section(stringResource(R.string.tags))
FlowRow {
it.forEach { item ->
SectionContent(item)
}
}
}
relayInfo.posting_policy?.let {
Section(stringResource(R.string.posting_policy))
Box(Modifier.padding(10.dp)) {
ClickableUrl(
it,
it
)
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplaySupportedNips(relayInfo: RelayInformation) {
FlowRow {
relayInfo.supported_nips?.forEach { item ->
val text = item.toString().padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
}
relayInfo.supported_nip_extensions?.forEach { item ->
val text = item.padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
}
}
}
@Composable
private fun DisplaySoftwareInformation(relayInfo: RelayInformation) {
val url = (relayInfo.software ?: "").replace("git+", "")
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = url,
url = url
)
}
}
@Composable
private fun DisplayOwnerInformation(
userHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)
}
}
@Composable
fun Title(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
Spacer(modifier = DoubleVertSpacer)
}
@Composable
fun Section(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Spacer(modifier = DoubleVertSpacer)
}
@Composable
fun SectionContent(text: String) {
Text(
modifier = Modifier.padding(start = 10.dp),
text = text
)
}
fun loadRelayInfo(
dirtyUrl: String,
context: Context,
scope: CoroutineScope,
onInfo: (RelayInformation) -> Unit
) {
val url = if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable: $body", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
)
}
@@ -1,83 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import java.util.Base64
@Immutable
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null
// Fixes Image size to reduce pings to servers for each size used in the app
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
}
}
@Composable
fun AsyncImageProxy(
model: ResizeImage,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
error: Painter? = null,
fallback: Painter? = error,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (model.url == null) {
AsyncImage(
model = model.url,
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
AsyncImage(
model = model.proxyUrl(),
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
@@ -0,0 +1,159 @@
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.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.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()
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)
)
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
// Just in case we want to use a webservice instead of directly contacting the mint
if (useWebService) {
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=true"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(context, intent, null)
} else {
if (lud16 != null) {
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 = 20.sp)
}
}
}
}
@@ -92,8 +92,8 @@ private fun LoadNote(
) {
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
LaunchedEffect(key1 = hex) {
if (noteBase == null) {
if (noteBase == null) {
LaunchedEffect(key1 = hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
@@ -186,8 +186,8 @@ private fun DisplayAddress(
) {
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
if (noteBase == null) {
if (noteBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
@@ -225,8 +225,8 @@ private fun DisplayUser(
) {
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
if (userBase == null) {
if (userBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
@@ -275,9 +275,10 @@ private fun RenderUserAsClickableText(
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = addedCharts,
tags = userTags,
maxLines = 1,
route = route,
nav = nav
nav = nav,
tags = userTags
)
}
}
@@ -286,24 +287,38 @@ private fun RenderUserAsClickableText(
fun CreateClickableText(
clickablePart: String,
suffix: String,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
nav: (String) -> Unit
) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
) {
val currentStyle = LocalTextStyle.current
val primaryColor = MaterialTheme.colors.primary
val onBackgroundColor = MaterialTheme.colors.onBackground
val clickablePartStyle = remember(primaryColor, overrideColor) {
currentStyle.copy(color = overrideColor ?: primaryColor, fontWeight = fontWeight).toSpanStyle()
}
val nonClickablePartStyle = remember(onBackgroundColor, overrideColor) {
currentStyle.copy(color = overrideColor ?: onBackgroundColor, fontWeight = fontWeight).toSpanStyle()
}
val text = remember(clickablePartStyle, nonClickablePartStyle, clickablePart, suffix) {
buildAnnotatedString {
withStyle(clickablePartStyle) {
append(clickablePart)
}
withStyle(
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()
) {
withStyle(nonClickablePartStyle) {
append(suffix)
}
},
}
}
ClickableText(
text = text,
maxLines = maxLines,
onClick = { nav(route) }
)
}
@@ -381,8 +396,8 @@ fun CreateTextWithEmoji(
) {
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
LaunchedEffect(key1 = text) {
if (emojis.isNotEmpty()) {
if (emojis.isNotEmpty()) {
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val newEmojiList = assembleAnnotatedList(text, emojis)
if (newEmojiList.isNotEmpty()) {
@@ -410,14 +425,17 @@ fun CreateTextWithEmoji(
modifier = modifier
)
} else {
val style = LocalTextStyle.current.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize
)
).toSpanStyle()
val currentStyle = LocalTextStyle.current
val style = remember(currentStyle) {
currentStyle.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize
)
).toSpanStyle()
}
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
}
@@ -426,6 +444,7 @@ fun CreateTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
maxLines: Int = Int.MAX_VALUE,
tags: ImmutableListOfLists<String>?,
style: TextStyle,
onClick: (Int) -> Unit
@@ -448,29 +467,37 @@ fun CreateClickableTextWithEmoji(
if (emojiList.isEmpty()) {
ClickableText(
AnnotatedString(clickablePart),
text = AnnotatedString(clickablePart),
style = style,
maxLines = maxLines,
onClick = onClick
)
} else {
ClickableInLineIconRenderer(emojiList, style.toSpanStyle()) {
ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) {
onClick(it)
}
}
}
@Immutable
data class DoubleEmojiList(
val part1: ImmutableList<Renderable>,
val part2: ImmutableList<Renderable>
)
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: ImmutableListOfLists<String>?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
nav: (String) -> Unit
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>?
) {
var emojiLists by remember(clickablePart) {
mutableStateOf<Pair<ImmutableList<Renderable>, ImmutableList<Renderable>>?>(null)
mutableStateOf<DoubleEmojiList?>(null)
}
LaunchedEffect(key1 = clickablePart) {
@@ -483,20 +510,28 @@ fun CreateClickableTextWithEmoji(
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
emojiLists = Pair(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
}
}
}
}
if (emojiLists == null) {
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav)
CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav)
} else {
ClickableInLineIconRenderer(emojiLists!!.first, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
ClickableInLineIconRenderer(
emojiLists!!.part1,
maxLines,
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
) {
nav(route)
}
InLineIconRenderer(emojiLists!!.second, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
InLineIconRenderer(
emojiLists!!.part2,
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle(),
maxLines
)
}
}
@@ -521,7 +556,12 @@ class TextType(val text: String) : Renderable()
class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
fun ClickableInLineIconRenderer(
wordsInOrder: ImmutableList<Renderable>,
maxLines: Int = Int.MAX_VALUE,
style: SpanStyle,
onClick: (Int) -> Unit
) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
@@ -574,6 +614,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList<Renderable>, style:
text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
}
@@ -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>()
}
@@ -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.background
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@@ -19,17 +19,16 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.HeadingStyle
@@ -44,10 +43,11 @@ 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.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
@@ -159,7 +159,6 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
@@ -262,7 +261,7 @@ private fun parseUrls(
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
@@ -286,6 +285,8 @@ private fun RenderWord(
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (word.startsWith("cashuA", true)) {
WordType.CASHU
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -341,6 +342,7 @@ private fun RenderWordWithoutPreview(
WordType.INVOICE -> NormalWord(wordSpace)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.CASHU -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
@@ -371,6 +373,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)
@@ -439,42 +442,6 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
paragraphSpacing = DefaultParagraphSpacing,
headingStyle = DefaultHeadingStyle,
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor.value)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor.value)
)
)
)
val uri = LocalUriHandler.current
val onClick = { link: String ->
val route = uriToRoute(link)
@@ -486,9 +453,9 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableSta
Unit
}
ProvideTextStyle(TextStyle(lineHeight = 1.30.em)) {
ProvideTextStyle(MarkdownTextStyle) {
MaterialRichText(
style = myMarkDownStyle
style = MaterialTheme.colors.markdownStyle
) {
RefreshableContent(content, tags) {
Markdown(
@@ -550,8 +517,8 @@ private fun ObserveNIP19Event(
) {
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseNote == null) {
if (baseNote == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
@@ -587,8 +554,8 @@ private fun ObserveNIP19User(
) {
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseUser == null) {
if (baseUser == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
@@ -785,7 +752,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
}
loadedLink = LoadedBechLink(returningNote, it)
val newLink = LoadedBechLink(returningNote, it)
launch(Dispatchers.Main) {
loadedLink = newLink
}
}
}
}
@@ -872,7 +843,9 @@ fun HashTag(word: String, nav: (String) -> Unit) {
}
if (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
launch(Dispatchers.Main) {
tagSuffixPair = Pair(myTag, mySuffix)
}
}
}
}
@@ -956,11 +929,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)
}
}
}
}
@@ -1027,22 +1004,25 @@ private fun DisplayUserFromTag(
addedChars: String,
nav: (String) -> Unit
) {
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${baseUser.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
val route = remember { "User/${baseUser.pubkeyHex}" }
val suffix = remember { "$addedChars " }
val hex = remember { baseUser.pubkeyDisplayHex() }
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "$addedChars " },
tags = userTags,
route = route,
nav = nav
)
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
Crossfade(targetState = meta) {
val displayName = remember(it) {
it?.bestDisplayName() ?: hex
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = it?.tags ?: ImmutableListOfLists()
)
}
}
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil.ImageLoader
@@ -31,6 +33,8 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
}
private fun svgString(msg: String): String {
checkNotInMainThread()
val hash = MessageDigest.getInstance("SHA-256").digest(msg.toByteArray())
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
@@ -58,6 +62,7 @@ private fun svgString(msg: String): String {
</svg>"""
}
@Stable
class HashImageFetcher(
private val context: Context,
private val data: Uri
@@ -93,6 +98,7 @@ object Robohash {
}
}
@Immutable
private data class Part(val style: String, val paths: String)
private const val background = """<polyline class="cls-bg" points="150.3 7.4 55.1 97.9 55.1 203.1 150.3 293.6 245.9 203.1 245.9 97.9 150.3 7.4"/>"""
@@ -10,16 +10,13 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import java.util.Date
@Composable
fun RobohashAsyncImage(
robot: String,
robotSize: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -50,13 +47,10 @@ fun RobohashAsyncImage(
)
}
var imageErrors = mapOf<String, Long>()
@Composable
fun RobohashFallbackAsyncImage(
robot: String,
robotSize: Dp,
model: String,
model: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -65,49 +59,30 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val errorCache = remember(imageErrors) { imageErrors[model] }
val context = LocalContext.current
val painter = rememberAsyncImagePainter(
model = Robohash.imageRequest(context, robot)
)
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val context = LocalContext.current
val painter = rememberAsyncImagePainter(
model = Robohash.imageRequest(context, robot)
)
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
onError = {
imageErrors = imageErrors + Pair(model, Date().time / 1000)
}
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
@Composable
fun RobohashAsyncImageProxy(
robot: String,
model: ResizeImage,
model: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -116,32 +91,15 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val proxy = remember(model) { model.proxyUrl() }
if (proxy == null) {
RobohashAsyncImage(
robot = robot,
robotSize = model.size,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
RobohashFallbackAsyncImage(
robot = robot,
robotSize = model.size,
model = proxy,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
RobohashFallbackAsyncImage(
robot = robot,
model = model,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
@@ -33,19 +33,34 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@Composable
fun SensitivityWarning(
hasSensitiveContent: Boolean,
note: Note,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
if (hasSensitiveContent) {
SensitivityWarning(accountViewModel, content)
} else {
content()
}
}
@Composable
fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
var showContentWarningNote by remember(accountState) {
mutableStateOf(accountState?.account?.showSensitiveContent != true && hasSensitiveContent)
mutableStateOf(accountState?.account?.showSensitiveContent != true)
}
if (showContentWarningNote) {
@@ -29,8 +29,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import kotlinx.collections.immutable.ImmutableList
@Composable
@@ -120,7 +120,7 @@ fun SpinnerSelectionDialog(
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = 14.sp)
Text(text = it, color = Color.Gray, fontSize = Font14SP)
}
}
}
@@ -21,8 +21,8 @@ fun UrlPreview(url: String, urlText: String) {
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) {
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
@@ -1,20 +1,20 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
@@ -37,6 +38,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -51,6 +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
@@ -73,9 +76,8 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -175,14 +177,7 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
mutableStateOf(false)
}
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
var mainImageModifier = MaterialTheme.colors.imageModifier
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
@@ -448,12 +443,33 @@ 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() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val pagerState: PagerState = remember { PagerState() }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
@@ -463,6 +479,18 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
Row(
modifier = Modifier
.padding(10.dp)
@@ -479,18 +507,6 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
@@ -34,9 +34,9 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val publicChannels = account.followingChannels().map { it ->
it.notes.values
.filter { account.isAcceptable(it) }
.filter { account.isAcceptable(it) && it.event != null }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
.lastOrNull()
}
return (privateMessages + publicChannels)
@@ -15,9 +15,11 @@ abstract class FeedFilter<T> {
}
Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects")
return feed.take(1000)
return feed.take(limit())
}
open fun limit() = 1000
/**
* Returns a string that serves as the key to invalidate the list if it changes.
*/
@@ -38,7 +40,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(1000)
sort(newList).take(limit())
} else {
oldList
}
@@ -26,6 +26,8 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val now = System.currentTimeMillis() / 1000
return collection
.asSequence()
.filter {
@@ -33,6 +35,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
((it.event?.createdAt() ?: 0) < now) &&
!it.isNewThread()
}
.toSet()
@@ -1,19 +1,19 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import okhttp3.Request
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList)?.size ?: 0
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList)?.size ?: 0
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList + "-" + followingKeySet + "-" + followingTagSet
}
override fun feed(): List<Note> {
@@ -32,13 +32,13 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val fortyEightHrs = (Date().time / 1000) - 60 * 60 * 48 // hrs
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > fortyEightHrs && noteEvent.status() == "live" && checkIfOnline(noteEvent.streaming())) &&
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
@@ -46,24 +46,9 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
.toSet()
}
override fun limit() = 2
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
fun checkIfOnline(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
HttpClient.getHttpClient().newCall(request).execute().code == 200
} catch (e: Exception) {
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import java.util.Date
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -31,6 +32,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val oneMinuteInTheFuture = Date().time / 1000 + (1 * 60) // one minute in the future.
val oneHr = 60 * 60
return collection
@@ -41,6 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
it.isNewThread() &&
(
(noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost
@@ -1,29 +0,0 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object PeopleListFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val lists = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is PeopleListEvent)
}
.toSet()
return lists
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -5,25 +5,17 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
val notes = user.latestBookmarkList?.taggedEvents()?.mapNotNull {
LocalCache.checkGetOrCreateNote(it)
}?.toSet() ?: emptySet()
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
val addresses = user.latestBookmarkList?.taggedAddresses()?.map {
LocalCache.getOrCreateAddressableNote(it)
}?.toSet() ?: emptySet()
@@ -4,23 +4,15 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
class UserProfileConversationsFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
return user?.notes
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
return user.notes
.filter { account.isAcceptable(it) == true && !it.isNewThread() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed() ?: emptyList()
}
}
@@ -8,27 +8,19 @@ import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
return user?.notes
?.plus(longFormNotes)
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
return user.notes
.plus(longFormNotes)
.filter { account.isAcceptable(it) == true && it.isNewThread() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed() ?: emptyList()
}
}
@@ -5,21 +5,13 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
var user: User? = null
class UserProfileReportsFeedFilter(val user: User) : FeedFilter<Note>() {
override fun feedKey(): String {
return user?.pubkeyHex ?: ""
}
fun loadUserProfile(user: User?) {
this.user = user
return user.pubkeyHex ?: ""
}
override fun feed(): List<Note> {
val myUser = user ?: return emptyList()
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(myUser.pubkeyHex) == true }
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(user.pubkeyHex) == true }
return reportNotes
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
@@ -8,12 +8,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -35,7 +34,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -43,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -52,12 +51,12 @@ import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -131,23 +130,28 @@ fun DisplayAccount(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel
) {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseUser by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(decodePublicKey(acc.npub).toHexKey())) }
LaunchedEffect(key1 = acc.npub) {
launch(Dispatchers.IO) {
baseUser = try {
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
} catch (e: Exception) {
null
if (baseUser == null) {
LaunchedEffect(key1 = acc.npub) {
launch(Dispatchers.IO) {
baseUser = try {
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
} catch (e: Exception) {
null
}
}
}
}
baseUser?.let {
Row(
modifier = Modifier.fillMaxWidth().clickable {
accountStateViewModel.switchUser(acc.npub)
}.padding(16.dp, 16.dp),
modifier = Modifier
.fillMaxWidth()
.clickable {
accountStateViewModel.switchUser(acc.npub)
}
.padding(16.dp, 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
@@ -159,7 +163,9 @@ fun DisplayAccount(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.width(55.dp).padding(0.dp)
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
AccountPicture(it)
}
@@ -197,21 +203,15 @@ private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) {
@Composable
private fun AccountPicture(user: User) {
val userState by user.live().metadata.observeAsState()
val profilePicture by remember(userState) {
derivedStateOf {
ResizeImage(userState?.user?.profilePicture(), 55.dp)
}
}
val profilePicture by user.live().metadata.map {
it.user.profilePicture()
}.observeAsState()
RobohashAsyncImageProxy(
robot = remember(user) { user.pubkeyHex },
model = profilePicture,
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
modifier = AccountPictureModifier
)
}
@@ -220,17 +220,13 @@ private fun AccountName(
acc: AccountInfo,
user: User
) {
val userState by user.live().metadata.observeAsState()
val displayName by remember(userState) {
derivedStateOf {
user.bestDisplayName()
}
}
val tags by remember(userState) {
derivedStateOf {
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
val displayName by user.live().metadata.map {
user.bestDisplayName()
}.observeAsState()
val tags by user.live().metadata.map {
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
}.observeAsState()
displayName?.let {
CreateTextWithEmoji(
@@ -251,8 +247,42 @@ private fun LogoutButton(
acc: AccountInfo,
accountStateViewModel: AccountStateViewModel
) {
var logoutDialog by remember { mutableStateOf(false) }
if (logoutDialog) {
AlertDialog(
title = {
Text(text = stringResource(R.string.log_out))
},
text = {
Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out))
},
onDismissRequest = {
logoutDialog = false
},
confirmButton = {
TextButton(
onClick = {
logoutDialog = false
accountStateViewModel.logOff(acc.npub)
}
) {
Text(text = stringResource(R.string.log_out))
}
},
dismissButton = {
TextButton(
onClick = {
logoutDialog = false
}
) {
Text(text = stringResource(R.string.cancel))
}
}
)
}
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
onClick = { logoutDialog = true }
) {
Icon(
imageVector = Icons.Default.Logout,
@@ -46,6 +46,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
@@ -74,7 +75,6 @@ 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.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
@@ -96,7 +96,8 @@ fun AppTopBar(
followLists: FollowListViewModel,
navEntryState: State<NavBackStackEntry?>,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
@@ -104,7 +105,7 @@ fun AppTopBar(
}
}
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel)
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel, nav)
}
@Composable
@@ -112,16 +113,17 @@ private fun RenderTopRouteBar(
currentRoute: String?,
followLists: FollowListViewModel,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (currentRoute) {
Route.Channel.base -> NoTopBar()
Route.Room.base -> NoTopBar()
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
else -> MainTopBar(scaffoldState, accountViewModel, nav)
}
}
@@ -130,15 +132,11 @@ fun NoTopBar() {
}
@Composable
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
val list by remember(accountState) {
derivedStateOf {
accountState?.account?.defaultStoriesFollowList ?: GLOBAL_FOLLOWS
}
}
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
val list by accountViewModel.accountLiveData.map {
it.account.defaultStoriesFollowList
}.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
@@ -151,15 +149,11 @@ fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState
}
@Composable
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
val list by remember(accountState) {
derivedStateOf {
accountState?.account?.defaultHomeFollowList ?: GLOBAL_FOLLOWS
}
}
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
val list by accountViewModel.accountLiveData.map {
it.account.defaultHomeFollowList
}.observeAsState(KIND3_FOLLOWS)
FollowList(
followLists,
@@ -172,15 +166,11 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
}
@Composable
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
val list by remember(accountState) {
derivedStateOf {
accountState?.account?.defaultNotificationFollowList ?: GLOBAL_FOLLOWS
}
}
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
val list by accountViewModel.accountLiveData.map {
it.account.defaultNotificationFollowList
}.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
@@ -193,15 +183,15 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) {
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
GenericTopBar(scaffoldState, accountViewModel, nav) {
AmethystIcon()
}
}
@OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (AccountViewModel) -> Unit) {
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit, content: @Composable (AccountViewModel) -> Unit) {
val coroutineScope = rememberCoroutineScope()
var wantsToEditRelays by remember {
@@ -209,7 +199,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
Column(modifier = Modifier.height(50.dp)) {
@@ -342,7 +332,7 @@ private fun LoggedInUserPictureDrawer(
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
val profilePicture = remember(accountUserState) { ResizeImage(accountUserState?.user?.profilePicture(), 34.dp) }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
IconButton(
onClick = onClick,
@@ -424,6 +414,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
var collectorJob: Job? = null
init {
Log.d("Init", "App Top Bar")
refresh()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
@@ -62,7 +62,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -122,12 +121,11 @@ fun ProfileContent(
val coroutineScope = rememberCoroutineScope()
val accountUserState by baseAccountUser.live().metadata.observeAsState()
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return
val profileBanner = remember(accountUserState) { accountUserState?.user?.info?.banner?.ifBlank { null } }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture()?.ifBlank { null }?.let { ResizeImage(it, 100.dp) } }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.bestDisplayName() }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
@@ -13,6 +13,7 @@ import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
@@ -165,6 +166,8 @@ object HomeLatestItem : LatestItem() {
account: Account,
newNotes: Set<Note>
): Boolean {
checkNotInMainThread()
val lastTime = account.loadLastRead("HomeFollows")
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
@@ -178,6 +181,8 @@ object NotificationLatestItem : LatestItem() {
account: Account,
newNotes: Set<Note>
): Boolean {
checkNotInMainThread()
val lastTime = account.loadLastRead("Notification")
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
@@ -191,6 +196,8 @@ object MessagesLatestItem : LatestItem() {
account: Account,
newNotes: Set<Note>
): Boolean {
checkNotInMainThread()
// Checks if the current newest item is still unread.
// If so, there is no need to check anything else
if (isNew(getNewestItem(account), account)) {
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -18,6 +19,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -39,17 +41,22 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -60,30 +67,65 @@ fun ChatroomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
val channelHex by remember(noteState) {
if (isBlank) {
BlankNote(Modifier)
} else {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
}
}
@Composable
fun ChatroomComposeChannelOrUser(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelHex by remember(baseNote) {
derivedStateOf {
noteState?.note?.channelHex()
baseNote.channelHex()
}
}
if (note?.event == null) {
BlankNote(Modifier)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
ChannelRoomCompose(note, channel, accountViewModel, nav)
}
if (channelHex != null) {
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
} else {
val userRoomHex = remember(noteState, accountViewModel) {
(note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
} ?: return
ChatroomDirectMessage(baseNote, accountViewModel, nav)
}
}
LoadUser(userRoomHex) { user ->
UserRoomCompose(note, user, accountViewModel, nav)
@Composable
private fun ChatroomDirectMessage(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val userRoomHex by remember(baseNote) {
derivedStateOf {
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
}
}
userRoomHex?.let {
LoadUser(it) { user ->
UserRoomCompose(baseNote, user, accountViewModel, nav)
}
}
}
@Composable
private fun ChatroomChannel(
channelHex: HexKey?,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
}
}
@Composable
@@ -126,11 +168,11 @@ private fun ChannelRoomCompose(
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
if (hasNewMessages.value != newHasNewMessages) {
hasNewMessages.value = newHasNewMessages
}
}
@@ -138,29 +180,7 @@ private fun ChannelRoomCompose(
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
withStyle(
SpanStyle(
color = MaterialTheme.colors.placeholderText,
fontWeight = FontWeight.Normal
)
) {
append(" ${stringResource(id = R.string.public_chat)}")
}
},
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
ChannelTitleWithBoostInfo(channelName, modifier)
},
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { "$authorName: $description" },
@@ -169,6 +189,39 @@ private fun ChannelRoomCompose(
)
}
@Composable
private fun ChannelTitleWithBoostInfo(channelName: String, modifier: Modifier) {
val boosted = stringResource(id = R.string.public_chat)
val placeHolderColor = MaterialTheme.colors.placeholderText
val channelNameAndBoostInfo = remember {
buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
withStyle(
SpanStyle(
color = placeHolderColor,
fontWeight = FontWeight.Normal
)
) {
append(" $boosted")
}
}
}
Text(
text = channelNameAndBoostInfo,
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
private fun UserRoomCompose(
note: Note,
@@ -176,29 +229,40 @@ private fun UserRoomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
val createAt by remember(note) {
derivedStateOf {
note.createdAt()
}
}
val content by remember(note) {
derivedStateOf {
accountViewModel.decrypt(note)
}
}
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
if (hasNewMessages.value != newHasNewMessages) {
hasNewMessages.value = newHasNewMessages
}
}
ChannelName(
channelPicture = {
UserPicture(
user,
NonClickableUserPicture(
baseUser = user,
accountViewModel = accountViewModel,
size = 55.dp
size = Size55dp
)
},
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
channelLastTime = createAt,
channelLastContent = content,
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
@@ -229,8 +293,8 @@ fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
}
LaunchedEffect(key1 = baseUserHex) {
if (user == null) {
if (user == null) {
LaunchedEffect(key1 = baseUserHex) {
launch(Dispatchers.IO) {
user = LocalCache.checkGetOrCreateUser(baseUserHex)
}
@@ -249,19 +313,21 @@ fun ChannelName(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
hasNewMessages: MutableState<Boolean>,
onClick: () -> Unit
) {
ChannelName(
channelPicture = {
RobohashAsyncImageProxy(
robot = channelIdHex,
model = ResizeImage(channelPicture, 55.dp),
model = channelPicture,
contentDescription = stringResource(R.string.channel_image),
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
modifier = remember {
Modifier
.width(Size55dp)
.height(Size55dp)
.clip(shape = CircleShape)
}
)
},
channelTitle,
@@ -278,71 +344,98 @@ fun ChannelName(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
hasNewMessages: MutableState<Boolean>,
onClick: () -> Unit
) {
val context = LocalContext.current
Column(modifier = Modifier.clickable(onClick = onClick)) {
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
modifier = remember { Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) }
) {
channelPicture()
Column(Size55Modifier) {
channelPicture()
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.padding(start = 10.dp),
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceAround
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
modifier = remember { Modifier.padding(bottom = 4.dp) }
) {
channelTitle(Modifier.weight(1f))
channelLastTime?.let {
Text(
timeAgo(channelLastTime, context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
)
}
FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) })
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (channelLastContent != null) {
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f)
)
} else {
Text(
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
if (hasNewMessages) {
NewItemsBubble()
}
SecondRow(channelLastContent, hasNewMessages, remember { Modifier.weight(1f) })
}
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
modifier = remember { Modifier.padding(top = 10.dp) },
thickness = 0.25.dp
)
}
}
@Composable
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()
}
}
@Composable
private fun FirstRow(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
modifier: Modifier
) {
channelTitle(modifier)
TimeAgo(channelLastTime)
}
@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
fun NewItemsBubble() {
Box(
@@ -359,6 +452,7 @@ fun NewItemsBubble() {
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.Center)
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,8 +26,8 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -33,6 +35,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,43 +47,47 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
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
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.actions.loadRelayInfo
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size13dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@OptIn(ExperimentalFoundationApi::class)
@@ -94,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
)
}
}
@@ -188,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
)
}
}
}
@@ -231,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))
}
}
@@ -261,11 +273,11 @@ fun NormalChatNote(
}
}
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
if (routeForLastRead != null) {
LaunchedEffect(key1 = routeForLastRead) {
val createdAt = note.createdAt()
if (createdAt != null) {
accountViewModel.account.markAsRead(it, createdAt)
accountViewModel.account.markAsRead(routeForLastRead, createdAt)
}
}
}
@@ -290,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 {
@@ -312,7 +324,9 @@ fun NormalChatNote(
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize.value = it
if (availableBubbleSize.value != it.width) {
availableBubbleSize.value = it.width
}
}
) {
Surface(
@@ -354,58 +368,90 @@ 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
}
}
}
Column(modifier = bubbleModifier) {
if (drawAuthorInfo) {
DrawAuthorInfo(
baseNote,
alignment,
nav
)
} else {
Spacer(modifier = StdVertSpacer)
}
MessageBubbleLines(
drawAuthorInfo,
baseNote,
alignment,
nav,
innerQuote,
backgroundBubbleColor,
accountViewModel,
onWantsToReply,
canPreview,
bubbleSize,
availableBubbleSize
)
}
}
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
@Composable
private fun MessageBubbleLines(
drawAuthorInfo: Boolean,
baseNote: Note,
alignment: Arrangement.Horizontal,
nav: (String) -> Unit,
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
onWantsToReply: (Note) -> Unit,
canPreview: Boolean,
bubbleSize: MutableState<Int>,
availableBubbleSize: MutableState<Int>
) {
if (drawAuthorInfo) {
DrawAuthorInfo(
baseNote,
alignment,
nav
)
} else {
Spacer(modifier = StdVertSpacer)
}
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
NoteRow(
note = baseNote,
canPreview = canPreview,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav
)
ConstrainedStatusRow(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize
) {
StatusRow(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
NoteRow(
note = baseNote,
canPreview = canPreview,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav
)
ConstrainedStatusRow(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize
) {
StatusRow(
baseNote = baseNote,
accountViewModel = accountViewModel,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -418,25 +464,42 @@ private fun RenderReplyRow(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val replyTo by remember {
val hasReply by remember {
derivedStateOf {
note.replyTo?.lastOrNull()
!innerQuote && note.replyTo?.lastOrNull() != null
}
}
if (!innerQuote && replyTo != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo?.let { note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
if (hasReply) {
RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply)
}
}
@Composable
private fun RenderReply(
note: Note,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val replyTo by remember {
derivedStateOf {
note.replyTo?.lastOrNull()
}
}
replyTo?.let { note ->
ChatroomMessageCompose(
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -473,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()
}
@@ -499,38 +561,52 @@ private fun ConstrainedStatusRow(
private fun StatusRow(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val grayTint = MaterialTheme.colors.placeholderText
val time = remember { baseNote.createdAt() ?: 0 }
Row(verticalAlignment = Alignment.CenterVertically) {
ChatTimeAgo(time)
RelayBadges(baseNote)
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = ReactionRowHeightChat) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
ChatTimeAgo(baseNote)
RelayBadges(baseNote, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = StdHorzSpacer)
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = Size16dp) {
onWantsToReply(baseNote)
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 = DoubleHorzSpacer)
ReplyReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colors.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSize = Size15dp
) {
onWantsToReply(baseNote)
}
Spacer(modifier = StdHorzSpacer)
}
}
}
@Composable
fun ChatTimeAgo(time: Long) {
fun ChatTimeAgo(baseNote: Note) {
val context = LocalContext.current
val timeStr = remember { timeAgoShort(time, context = context) }
val timeStr by remember(baseNote) {
derivedStateOf {
timeAgoShort(baseNote.createdAt() ?: 0, context = context)
}
}
Text(
timeStr,
text = timeStr,
color = MaterialTheme.colors.placeholderText,
fontSize = 12.sp
fontSize = 12.sp,
maxLines = 1
)
}
@@ -547,10 +623,8 @@ private fun RenderRegularTextNote(
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
note = note,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
@@ -630,140 +704,233 @@ private fun DrawAuthorInfo(
alignment: Arrangement.Horizontal,
nav: (String) -> Unit
) {
val userState by baseNote.author!!.live().metadata.observeAsState()
val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return
val route = remember { "User/$pubkeyHex" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
RobohashAsyncImageProxy(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = remember {
Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
nav(route)
})
}
)
userDisplayName?.let {
Spacer(modifier = StdHorzSpacer)
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = "",
tags = userTags,
fontWeight = FontWeight.Bold,
overrideColor = MaterialTheme.colors.onBackground,
route = route,
nav = nav
)
Spacer(modifier = StdHorzSpacer)
DrawPlayName(it)
}
DisplayAndWatchNoteAuthor(baseNote, nav)
}
}
@Immutable
data class RelayBadgesState(
val shouldDisplayExpandButton: Boolean,
val noteRelays: ImmutableList<String>,
val noteRelaysSimple: ImmutableList<String>
)
@Composable
private fun DisplayAndWatchNoteAuthor(
baseNote: Note,
nav: (String) -> Unit
) {
val author = remember {
baseNote.author
}
author?.let {
WatchAndDisplayUser(it, nav)
}
}
@Composable
private fun WatchAndDisplayUser(
author: User,
nav: (String) -> Unit
) {
val pubkeyHex = remember { author.pubkeyHex }
val route = remember { "User/${author.pubkeyHex}" }
val userState by author.live().metadata.observeAsState()
val userDisplayName by remember(userState) {
derivedStateOf {
userState?.user?.toBestDisplayName()
}
}
val userProfilePicture by remember(userState) {
derivedStateOf {
userState?.user?.profilePicture()
}
}
val userTags by remember(userState) {
derivedStateOf {
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
UserIcon(pubkeyHex, userProfilePicture, nav, route)
userDisplayName?.let {
DisplayMessageUsername(it, userTags, route, nav)
}
}
@Composable
private fun UserIcon(
pubkeyHex: String,
userProfilePicture: String?,
nav: (String) -> Unit,
route: String
) {
RobohashAsyncImageProxy(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = remember {
Modifier
.width(Size25dp)
.height(Size25dp)
.clip(shape = CircleShape)
.clickable(onClick = {
nav(route)
})
}
)
}
@Composable
private fun DisplayMessageUsername(
userDisplayName: String,
userTags: ImmutableListOfLists<String>?,
route: String,
nav: (String) -> Unit
) {
Spacer(modifier = StdHorzSpacer)
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "",
maxLines = 1,
tags = userTags,
fontWeight = FontWeight.Bold,
overrideColor = MaterialTheme.colors.onBackground, // we do not want clickable names in purple here.
route = route,
nav = nav
)
Spacer(modifier = StdHorzSpacer)
DrawPlayName(userDisplayName)
}
@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) {
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)
}
}
if (state.shouldDisplayExpandButton && !expanded) {
IconButton(
modifier = Size15Modifier,
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.placeholderText
)
RenderRelay(it, accountViewModel, nav)
}
}
}
@Composable
fun RenderRelay(dirtyUrl: String) {
val uri = LocalUriHandler.current
val website = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl"
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()
}
val iconUrl = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
@Composable
fun ChatRelayExpandButton(onClick: () -> Unit) {
IconButton(
modifier = Size15Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
}
@Composable
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val iconUrl by remember(dirtyUrl) {
derivedStateOf {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
}
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
if (relayInfo != null) {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
)
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = Size15dp)
val clickableModifier = remember(dirtyUrl) {
Modifier
.padding(1.dp)
.size(15.dp)
.clickable(onClick = { uri.openUri(website) })
}
val backgroundColor = MaterialTheme.colors.background
val iconModifier = remember(dirtyUrl) {
Modifier
.size(13.dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
.size(Size15dp)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
indication = ripple,
onClick = {
loadRelayInfo(dirtyUrl, context, scope) {
relayInfo = it
}
}
)
}
Box(
modifier = clickableModifier
) {
RobohashFallbackAsyncImage(
robot = iconUrl,
robotSize = Size13dp,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = RelayIconFilter,
modifier = iconModifier
)
RenderRelayIcon(iconUrl)
}
}
@Composable
private fun RenderRelayIcon(iconUrl: String) {
val backgroundColor = MaterialTheme.colors.background
val iconModifier = remember {
Modifier
.size(Size13dp)
.clip(shape = CircleShape)
.drawBehind { drawRect(backgroundColor) }
}
RobohashFallbackAsyncImage(
robot = iconUrl,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = RelayIconFilter,
modifier = iconModifier
)
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -48,6 +49,7 @@ 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
@@ -58,6 +60,7 @@ 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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import kotlinx.collections.immutable.ImmutableList
@@ -89,7 +92,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
}
@@ -119,7 +124,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
Galeries(multiSetCard, backgroundColor, nav, accountViewModel)
Row(remember { Modifier.fillMaxWidth() }) {
Spacer(modifier = remember { Modifier.width(65.dp) })
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
NoteCompose(
baseNote = baseNote,
@@ -180,7 +185,7 @@ fun RenderLikeGallery(
val shortReaction by remember {
derivedStateOf {
reactionType.take(2)
reactionType.firstFullChar()
}
}
@@ -335,28 +340,33 @@ private fun AuthorPictureAndComment(
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 }
}
}
}
}
}
Row(
modifier = Modifier.clickable {
nav("User/${content.value.user?.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(
authorComment = content,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel
)
Crossfade(targetState = content) {
Row(
modifier = Modifier.clickable {
nav("User/${it.value.user?.pubkeyHex}")
},
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(
authorComment = it,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel
)
}
}
}
@@ -364,7 +374,9 @@ val amountBoxModifier = Modifier
.fillMaxSize()
.clip(shape = CircleShape)
val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth()
val textBoxModifier = Modifier
.padding(start = 5.dp)
.fillMaxWidth()
val simpleModifier = Modifier
@@ -515,6 +527,22 @@ fun FastNoteAuthorPicture(
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)
@@ -530,7 +558,7 @@ fun FastNoteAuthorPicture(
}
}
UserPicture(
PictureAndFollowingMark(
userHex = authorPubKey,
userPicture = profilePicture,
size = size,
@@ -545,7 +573,9 @@ fun WatchUserMetadata(userBase: User, onMetadataChanges: (UserMetadata) -> Unit)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.Default) {
userState?.user?.info?.let {
onMetadataChanges(it)
launch(Dispatchers.Main) {
onMetadataChanges(it)
}
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -28,6 +29,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -52,8 +54,8 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): MutableSta
mutableStateOf(default)
}
LaunchedEffect(key1 = user.nip05) {
if (nip05Verified.value == null) {
if (nip05Verified.value == null) {
LaunchedEffect(key1 = user.nip05) {
launch(Dispatchers.IO) {
user.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
@@ -109,21 +111,16 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val isValidNIP05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()?.split("@")?.size == 2
}
}
val nip05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()
}
}
val nip05 by baseUser.live().metadata.map {
it.user.nip05()
}.observeAsState()
if (isValidNIP05) {
nip05?.let {
DisplayNIP05Line(it, baseUser, columnModifier)
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
val isValid = it.split("@").size == 2
if (isValid) {
DisplayNIP05Line(it, baseUser, columnModifier)
}
}
}
}
@@ -131,9 +128,11 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
DisplayNIP05(nip05, nip05Verified)
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
Crossfade(targetState = nip05Verified) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayNIP05(nip05, it)
}
}
}
}
@@ -150,7 +149,7 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = AnnotatedString(user),
text = remember(nip05) { AnnotatedString(user) },
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -160,7 +159,7 @@ private fun DisplayNIP05(
NIP05VerifiedSymbol(nip05Verified)
ClickableText(
text = AnnotatedString(domain),
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
File diff suppressed because it is too large Load Diff
@@ -36,11 +36,10 @@ import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -112,8 +111,8 @@ private fun VerticalDivider(color: Color) =
@Composable
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
var popupExpanded = remember { mutableStateOf(false) }
val showPopup = remember(popupExpanded) { { popupExpanded.value = true } }
val popupExpanded = remember { mutableStateOf(false) }
val showPopup = remember { { popupExpanded.value = true } }
content(showPopup)
@@ -122,192 +121,241 @@ fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, c
@Composable
fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
var showDeleteAlertDialog by remember(note) { mutableStateOf(false) }
var showBlockAlertDialog by remember(note) { mutableStateOf(false) }
var showReportDialog by remember(note) { mutableStateOf(false) }
val showSelectTextDialog = remember { mutableStateOf(false) }
val showDeleteAlertDialog = remember { mutableStateOf(false) }
val showBlockAlertDialog = remember { mutableStateOf(false) }
val showReportDialog = remember { mutableStateOf(false) }
if (popupExpanded) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
RenderMainPopup(
accountViewModel,
note,
onDismiss,
showBlockAlertDialog,
showDeleteAlertDialog,
showReportDialog
)
}
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.secondaryButtonBackground
if (showSelectTextDialog.value) {
accountViewModel.decrypt(note)?.let {
SelectTextDialog(it) { showSelectTextDialog.value = false }
}
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
if (showDeleteAlertDialog.value) {
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog.value = false
onDismiss()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
if (showBlockAlertDialog.value) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog.value = false
onDismiss()
}
}
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
if (showReportDialog.value) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog.value = false
onDismiss()
}
}
}
@Composable
private fun RenderMainPopup(
accountViewModel: AccountViewModel,
note: Note,
onDismiss: () -> Unit,
showBlockAlertDialog: MutableState<Boolean>,
showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.secondaryButtonBackground
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog = true
}
}
}
}
Divider(
color = primaryLight,
modifier = Modifier
.fillMaxWidth()
.width(1.dp)
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestDialog) {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note)
)
putExtra(Intent.EXTRA_TITLE, context.getString(R.string.quick_action_share_browser_link))
}
val shareIntent = Intent.createChooser(sendIntent, context.getString(R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringResource(R.string.quick_action_copy_user_id)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.FormatQuote,
stringResource(R.string.quick_action_copy_note_id)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
showReportDialog = true
NoteQuickActionItem(
Icons.Default.Block,
stringResource(R.string.quick_action_block)
) {
if (accountViewModel.hideBlockAlertDialog) {
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog.value = true
}
}
}
}
Divider(
color = primaryLight,
modifier = Modifier
.fillMaxWidth()
.width(1.dp)
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(
Icons.Default.Delete,
stringResource(R.string.quick_action_delete)
) {
if (accountViewModel.hideDeleteRequestDialog) {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog.value = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(
Icons.Default.PersonRemove,
stringResource(R.string.quick_action_unfollow)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(
Icons.Default.PersonAdd,
stringResource(R.string.quick_action_follow)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringResource(R.string.quick_action_share)
) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note)
)
putExtra(
Intent.EXTRA_TITLE,
context.getString(R.string.quick_action_share_browser_link)
)
}
val shareIntent = Intent.createChooser(
sendIntent,
context.getString(R.string.quick_action_share)
)
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.Report,
stringResource(R.string.quick_action_report)
) {
showReportDialog.value = true
}
}
}
}
}
}
if (showSelectTextDialog) {
accountViewModel.decrypt(note)?.let {
SelectTextDialog(it) { showSelectTextDialog = false }
}
}
if (showDeleteAlertDialog) {
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog = false
onDismiss()
}
}
if (showBlockAlertDialog) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog = false
onDismiss()
}
}
if (showReportDialog) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog = false
onDismiss()
}
}
}
@Composable
@@ -20,7 +20,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -32,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -428,9 +428,12 @@ fun ZapVote(
// only show tallies after a user has zapped note
if (!pollViewModel.canZap()) {
val amountStr = remember(poolOption.zappedValue) {
showAmount(poolOption.zappedValue)
}
Text(
showAmount(poolOption.zappedValue),
fontSize = 14.sp,
text = amountStr,
fontSize = Font14SP,
color = MaterialTheme.colors.placeholderText,
modifier = modifier
)
@@ -2,7 +2,13 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.with
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -17,7 +23,6 @@ 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
@@ -45,12 +50,13 @@ import androidx.compose.runtime.remember
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
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -59,21 +65,45 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
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
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiserSize
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size17dp
import com.vitorpamplona.amethyst.ui.theme.Size19dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
import com.vitorpamplona.amethyst.ui.theme.subtleButton
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
@@ -84,67 +114,127 @@ import java.math.RoundingMode
import kotlin.math.roundToInt
@Composable
fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val grayTint = MaterialTheme.colors.placeholderText
fun ReactionsRow(
baseNote: Note,
showReactionDetail: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val wantsToSeeReactions = remember {
mutableStateOf<Boolean>(false)
mutableStateOf(false)
}
val zapraiserAmount = remember {
baseNote.event?.zapraiserAmount()
}
Spacer(modifier = HalfDoubleVertSpacer)
Spacer(modifier = Modifier.height(7.dp))
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp)) {
if (showReactionDetail) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.width(65.dp)
.padding(start = 31.dp),
horizontalArrangement = Arrangement.Start
) {
ExpandButton(baseNote, wantsToSeeReactions)
}
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ReplyReactionWithDialog(accountViewModel, nav, baseNote, grayTint)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
BoostWithDialog(accountViewModel, nav, baseNote, grayTint)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ZapReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ViewCountReaction(baseNote.idHex, grayTint)
}
}
if (zapraiserAmount != null && zapraiserAmount > 0) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = if (showReactionDetail) 75.dp else 0.dp),
horizontalArrangement = Arrangement.Start
) {
RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel)
}
}
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
}
Spacer(modifier = Modifier.height(7.dp))
Spacer(modifier = HalfDoubleVertSpacer)
}
@Composable
private fun InnerReactionRow(
baseNote: Note,
showReactionDetail: Boolean,
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) {
if (showReactionDetail) {
Column(
verticalArrangement = Arrangement.Center,
modifier = ReactionRowExpandButton
) {
Row(verticalAlignment = CenterVertically) {
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) {
RenderShowIndividualReactionsButton(wantsToSeeReactions)
}
}
}
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ReplyReactionWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
}
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
BoostWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
}
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
}
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
}
}
Column(
verticalArrangement = Arrangement.Center,
modifier = remember { Modifier.weight(1f) }
) {
Row(verticalAlignment = CenterVertically) {
ViewCountReaction(
note = baseNote,
grayTint = MaterialTheme.colors.placeholderText,
viewCountColorFilter = MaterialTheme.colors.placeholderTextColorFilter
)
}
}
}
}
@Composable
private fun LoadAndDisplayZapraiser(
baseNote: Note,
showReactionDetail: Boolean,
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel
) {
val zapraiserAmount by remember {
derivedStateOf {
baseNote.event?.zapraiserAmount() ?: 0
}
}
if (zapraiserAmount > 0) {
Spacer(modifier = Height4dpModifier)
Box(
modifier = remember {
ReactionRowZapraiserSize
.padding(start = if (showReactionDetail) Size75dp else Size0dp)
},
contentAlignment = CenterStart
) {
RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel)
}
}
}
@Composable
@@ -165,11 +255,17 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
}
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
zapraiserProgress = percentage
if (percentage > 0.99) {
zapraiserLeft = "0"
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
zapraiserLeft = showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
if (zapraiserLeft != newZapraiserLeft) {
zapraiserLeft = newZapraiserLeft
}
if (zapraiserProgress != newZapraiserProgress) {
zapraiserProgress = newZapraiserProgress
}
}
}
@@ -177,74 +273,102 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
}
val color = if (zapraiserProgress > 0.99) {
Color.Green.copy(alpha = 0.32f)
DarkerGreen
} else {
MaterialTheme.colors.mediumImportanceLink
}
Box(
Modifier.padding(end = 10.dp).fillMaxWidth()
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = zapraiserProgress
)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp),
color = color,
progress = zapraiserProgress
)
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(2.dp)
if (details) {
Box(
contentAlignment = Center,
modifier = TinyBorders
) {
if (details) {
val totalPercentage = remember(zapraiserProgress) {
val totalPercentage by remember(zapraiserProgress) {
derivedStateOf {
"${(zapraiserProgress * 100).roundToInt()}%"
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
modifier = Modifier.padding(start = 5.dp, end = 5.dp, top = 2.dp, bottom = 2.dp),
color = MaterialTheme.colors.placeholderText,
fontSize = 14.sp
)
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
modifier = NoSoTinyBorders,
color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP,
maxLines = 1
)
}
}
}
@Composable
private fun ExpandButton(baseNote: Note, wantsToSeeReactions: MutableState<Boolean>) {
val zapsState by baseNote.live().zaps.observeAsState()
val boostsState by baseNote.live().boosts.observeAsState()
val reactionsState by baseNote.live().reactions.observeAsState()
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) {
IconButton(
onClick = {
wantsToSeeReactions.value = !wantsToSeeReactions.value
},
modifier = Modifier.size(20.dp)
) {
if (wantsToSeeReactions.value) {
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
private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableState<Boolean>) {
IconButton(
onClick = {
wantsToSeeReactions.value = !wantsToSeeReactions.value
},
modifier = Size20Modifier
) {
Crossfade(targetState = wantsToSeeReactions.value) {
if (it) {
Icon(
imageVector = Icons.Default.ExpandLess,
null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
modifier = Size22Modifier,
tint = MaterialTheme.colors.subtleButton
)
} else {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
modifier = Size22Modifier,
tint = MaterialTheme.colors.subtleButton
)
}
}
@@ -320,10 +444,10 @@ private fun ReactionDetailGallery(
@Composable
private fun BoostWithDialog(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
baseNote: Note,
grayTint: Color
grayTint: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var wantsToQuote by remember {
mutableStateOf<Note?>(null)
@@ -340,10 +464,10 @@ private fun BoostWithDialog(
@Composable
private fun ReplyReactionWithDialog(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
baseNote: Note,
grayTint: Color
grayTint: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
@@ -364,7 +488,7 @@ fun ReplyReaction(
grayTint: Color,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 18.dp,
iconSize: Dp = Size17dp,
onPress: () -> Unit
) {
val context = LocalContext.current
@@ -409,20 +533,63 @@ fun ReplyReaction(
@Composable
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.observeAsState()
val replyCount by remember(repliesState) {
derivedStateOf {
" " + showCount(repliesState?.note?.replies?.size)
}
}
val repliesState by baseNote.live().replies.map {
it.note.replies.size
}.observeAsState(baseNote.replies.size)
SlidingAnimation(repliesState, textColor)
}
@Composable
@OptIn(ExperimentalAnimationApi::class)
private fun SlidingAnimation(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = baseCount,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } with slideOutVertically { it }
} else {
slideInVertically { it } with slideOutVertically { -it }
}
}
) { count ->
TextCount(count, textColor)
}
}
@Composable
private fun TextCount(count: Int, textColor: Color) {
Text(
text = replyCount,
fontSize = 14.sp,
color = textColor
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>(
targetState = amount,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } with slideOutVertically { it }
} else {
slideInVertically { it } with slideOutVertically { -it }
}
}
) { count ->
Text(
text = count,
fontSize = Font14SP,
color = textColor,
maxLines = 1
)
}
}
@Composable
fun BoostReaction(
baseNote: Note,
@@ -462,35 +629,31 @@ fun BoostReaction(
}
}
) {
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
}
BoostIcon(baseNote, iconSize, grayTint, accountViewModel)
}
BoostIcon(baseNote, iconSize, grayTint, accountViewModel.userProfile())
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
}
BoostText(baseNote, grayTint)
}
@Composable
fun BoostIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
val boostsState by baseNote.live().boosts.observeAsState()
val iconTint by remember(boostsState) {
derivedStateOf {
if (boostsState?.note?.isBoostedBy(loggedIn) == true) Color.Unspecified else grayTint
}
}
fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountViewModel: AccountViewModel) {
val iconTint by baseNote.live().boosts.map {
if (it.note.isBoostedBy(accountViewModel.userProfile())) Color.Unspecified else grayTint
}.distinctUntilChanged().observeAsState(grayTint)
val iconModifier = remember {
Modifier.size(iconSize)
@@ -506,18 +669,11 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: U
@Composable
fun BoostText(baseNote: Note, grayTint: Color) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostCount by remember(boostsState) {
derivedStateOf {
" " + showCount(boostsState?.note?.boosts?.size)
}
}
val boostState by baseNote.live().boosts.map {
it.note.boosts.size
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
Text(
boostCount,
fontSize = 14.sp,
color = grayTint
)
SlidingAnimation(boostState, grayTint)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -528,7 +684,7 @@ fun LikeReaction(
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp,
iconFontSize: TextUnit = 14.sp
iconFontSize: TextUnit = Font14SP
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -540,13 +696,12 @@ fun LikeReaction(
var wantsToChangeReactionSymbol by remember { mutableStateOf(false) }
var wantsToReact by remember { mutableStateOf(false) }
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.Center,
Box(
contentAlignment = Center,
modifier = iconButtonModifier.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
indication = rememberRipple(bounded = false, radius = Size24dp),
onClick = {
likeClick(
baseNote,
@@ -563,37 +718,37 @@ fun LikeReaction(
}
)
) {
LikeIcon(baseNote, iconFontSize, heartSize, grayTint, accountViewModel.userProfile())
if (wantsToChangeReactionSymbol) {
UpdateReactionTypeDialog({ wantsToChangeReactionSymbol = false }, accountViewModel = accountViewModel)
}
if (wantsToReact) {
ReactionChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToReact = false
},
onChangeAmount = {
wantsToReact = false
wantsToChangeReactionSymbol = true
}
)
}
LikeIcon(baseNote, iconFontSize, heartSize, grayTint, accountViewModel)
}
LikeText(baseNote, grayTint)
if (wantsToChangeReactionSymbol) {
UpdateReactionTypeDialog({ wantsToChangeReactionSymbol = false }, accountViewModel = accountViewModel)
}
if (wantsToReact) {
ReactionChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToReact = false
},
onChangeAmount = {
wantsToReact = false
wantsToChangeReactionSymbol = true
}
)
}
}
@Composable
fun LikeIcon(
baseNote: Note,
iconFontSize: TextUnit = 14.sp,
iconSize: Dp = 20.dp,
iconFontSize: TextUnit = Font14SP,
iconSize: Dp = Size20dp,
grayTint: Color,
loggedIn: User
accountViewModel: AccountViewModel
) {
val reactionsState by baseNote.live().reactions.observeAsState()
@@ -603,37 +758,63 @@ fun LikeIcon(
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newReactionType = reactionsState?.note?.isReactedBy(loggedIn)
val newReactionType = reactionsState?.note?.isReactedBy(accountViewModel.userProfile())?.firstFullChar()
if (reactionType != newReactionType) {
reactionType = newReactionType?.take(2)
launch(Dispatchers.Main) {
reactionType = newReactionType
}
}
}
}
Crossfade(targetState = reactionType) {
if (it != null) {
RenderReactionType(it, iconSize, iconFontSize)
} else {
RenderLikeIcon(iconSize, grayTint)
}
}
}
@Composable
private fun RenderLikeIcon(
iconSize: Dp = Size20dp,
grayTint: Color
) {
val iconModifier = remember {
Modifier.size(iconSize)
}
if (reactionType != null) {
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
}
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize)
else -> Text(text = reactionType!!, fontSize = iconFontSize)
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
}
@Composable
private fun RenderReactionType(
reactionType: String,
iconSize: Dp = Size20dp,
iconFontSize: TextUnit
) {
val iconModifier = remember {
Modifier.size(iconSize)
}
when (reactionType) {
"+" -> {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
}
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
"-" -> Text(text = "\uD83D\uDC4E", fontSize = iconFontSize)
else -> Text(text = reactionType, fontSize = iconFontSize)
}
}
@@ -641,24 +822,22 @@ fun LikeIcon(
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsState by baseNote.live().reactions.observeAsState()
var reactionsCount by remember(reactionsState) {
mutableStateOf("")
var reactionsCount by remember(baseNote) {
mutableStateOf(baseNote.reactions.size)
}
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newReactionsCount = " " + showCount(reactionsState?.note?.countReactions())
val newReactionsCount = reactionsState?.note?.countReactions() ?: 0
if (reactionsCount != newReactionsCount) {
reactionsCount = newReactionsCount
launch(Dispatchers.Main) {
reactionsCount = newReactionsCount
}
}
}
}
Text(
reactionsCount,
fontSize = 14.sp,
color = grayTint
)
SlidingAnimation(reactionsCount, grayTint)
}
private fun likeClick(
@@ -727,7 +906,7 @@ fun ZapReaction(
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
indication = rememberRipple(bounded = false, radius = Size24dp),
onClick = {
zapClick(
baseNote,
@@ -785,15 +964,13 @@ fun ZapReaction(
}
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(Modifier.width(3.dp))
val animatedProgress = animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
).value
Spacer(ModifierWidth3dp)
CircularProgressIndicator(
progress = animatedProgress,
progress = animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
).value,
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp
)
@@ -884,27 +1061,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
)
}
}
}
@@ -923,70 +1104,70 @@ private fun ZapAmountText(
zapsState?.note?.let {
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(it))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
launch(Dispatchers.Main) {
zapAmountTxt = newZapAmount
}
}
}
}
}
Text(
zapAmountTxt,
fontSize = 14.sp,
color = grayTint
)
SlidingAnimation(zapAmountTxt, grayTint)
}
@Composable
fun ViewCountReaction(
idHex: String,
note: Note,
grayTint: Color,
iconSize: Dp = 20.dp,
barChartSize: Dp = 19.dp,
numberSize: Dp = 24.dp
barChartSize: Dp = Size19dp,
numberSize: Dp = Size24dp,
viewCountColorFilter: ColorFilter
) {
val uri = LocalUriHandler.current
val context = LocalContext.current
DrawViewCountIcon(barChartSize, grayTint)
DrawViewCount(note, numberSize, viewCountColorFilter)
}
@Composable
private fun DrawViewCountIcon(
barChartSize: Dp = Size19dp,
grayTint: Color
) {
val iconButtonModifier = remember {
Modifier.size(barChartSize)
}
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = iconButtonModifier,
tint = grayTint
)
}
@Composable
private fun DrawViewCount(
note: Note,
numberSize: Dp = Size24dp,
viewCountColorFilter: ColorFilter
) {
val context = LocalContext.current
val iconModifier = remember {
Modifier.height(numberSize)
}
val colorFilter = remember {
ColorFilter.tint(grayTint)
}
IconButton(
modifier = Modifier.size(iconSize),
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = iconButtonModifier,
tint = grayTint
)
}
val request = remember {
ImageRequest.Builder(context)
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
}
Row {
AsyncImage(
model = request,
contentDescription = stringResource(R.string.view_count),
modifier = iconModifier,
colorFilter = colorFilter
)
}
AsyncImage(
model = remember(note) {
ImageRequest.Builder(context)
.data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
},
contentDescription = stringResource(R.string.view_count),
modifier = iconModifier,
colorFilter = viewCountColorFilter
)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -11,8 +11,10 @@ import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -37,9 +39,6 @@ fun RelayCompose(
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current
Column() {
@@ -60,8 +59,14 @@ fun RelayCompose(
overflow = TextOverflow.Ellipsis
)
val lastTime by remember(relay.lastEvent) {
derivedStateOf {
timeAgo(relay.lastEvent, context = context)
}
}
Text(
timeAgo(relay.lastEvent, context = context),
text = lastTime,
maxLines = 1
)
}
@@ -75,11 +80,7 @@ fun RelayCompose(
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.activeRelays()?.none { it.url == relay.url } == true) {
AddRelayButton { onAddRelay() }
} else {
RemoveRelayButton { onRemoveRelay() }
}
RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay)
}
}
@@ -90,6 +91,26 @@ fun RelayCompose(
}
}
@Composable
private fun RelayOptions(
accountViewModel: AccountViewModel,
relay: RelayInfo,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val userState by accountViewModel.userRelays.observeAsState()
val isNotUsingRelay = remember(userState) {
accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true
}
if (isNotUsingRelay) {
AddRelayButton(onAddRelay)
} else {
RemoveRelayButton(onRemoveRelay)
}
}
@Composable
fun AddRelayButton(onClick: () -> Unit) {
Button(
@@ -45,6 +45,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,7 +65,7 @@ class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
}
fun addChoice() {
val newValue = nextChoice.text.trim().take(2)
val newValue = nextChoice.text.trim().firstFullChar()
reactionSet = reactionSet + newValue
nextChoice = TextFieldValue("")
@@ -60,7 +60,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
@@ -78,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -415,7 +415,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText,
fontSize = 14.sp
fontSize = Font14SP
)
}
@@ -6,22 +6,26 @@ 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(
baseUser: User,
overallModifier: Modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
overallModifier: Modifier = remember {
Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
},
showDiviser: Boolean = true,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -35,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)
}
@@ -45,14 +49,16 @@ fun UserCompose(
AboutDisplay(baseUser)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
UserActionOptions(baseUser, accountViewModel)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
if (showDiviser) {
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -293,6 +294,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
var collectorJob: Job? = null
init {
Log.d("Init", "User Reactions Row")
viewModelScope.launch(Dispatchers.IO) {
initializeSuspend()
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -10,6 +12,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@@ -19,11 +22,11 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -31,23 +34,36 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val author = remember(noteState) {
noteState?.note?.author
} ?: return
val authorState by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
UsernameDisplay(author, weight)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
}
}
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
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)
}
}
}
@Composable
@@ -58,11 +74,61 @@ private fun UserNameDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName, tags, modifier)
} else if (bestUserName != null) {
UserDisplay(bestUserName, tags, modifier)
} else {
NPubDisplay(npubDisplay, modifier)
}
}
@Composable
private fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
Text(
text = npubDisplay,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
@Composable
private fun UserDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
@Composable
private fun UserAndUsernameDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
bestUserName: String,
modifier: Modifier
) {
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1
)
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
@@ -74,36 +140,6 @@ private fun UserNameDisplay(
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
} else if (bestDisplayName != null) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
} else if (bestUserName != null) {
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestUserName)
} else {
Text(
npubDisplay,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
}
@@ -112,8 +148,15 @@ fun DrawPlayName(name: String) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
DrawPlayNameIcon {
speak(name, context, lifecycleOwner)
}
}
@Composable
fun DrawPlayNameIcon(onClick: () -> Unit) {
IconButton(
onClick = { speak(name, context, lifecycleOwner) },
onClick = onClick,
modifier = StdButtonSizeModifier
) {
Icon(
@@ -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 {
@@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -76,7 +75,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), 100.dp),
model = user.profilePicture(),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(100.dp)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
@@ -29,6 +30,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
val accountContent = _accountContent.asStateFlow()
init {
Log.d("Init", "AccountStateViewModel")
// pulls account from storage.
// Keeps it in the the UI thread to void blinking the login page.
@@ -101,7 +101,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val updatedCards = (oldNotesState.feed.value + newCards)
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.take(1000)
.take(localFilter.limit())
.toImmutableList()
if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) {
@@ -115,7 +115,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val cards = convertToCard(notes)
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.take(1000)
.take(localFilter.limit())
.toImmutableList()
updateFeed(cards)
@@ -255,7 +255,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val updatedCards = (oldNotesState.feed.value + newCards)
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
.reversed()
.take(1000)
.take(localFilter.limit())
.toImmutableList()
if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) {
@@ -329,6 +329,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
var collectorJob: Job? = null
init {
Log.d("Init", "${this.javaClass.simpleName}")
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
@@ -2,35 +2,23 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ChatroomListFeedView(
viewModel: FeedViewModel,
@@ -38,46 +26,45 @@ fun ChatroomListFeedView(
nav: (String) -> Unit,
markAsRead: MutableState<Boolean>
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
RefresheableView(viewModel, true) {
CrossFadeState(viewModel, accountViewModel, nav, markAsRead)
}
}
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
@Composable
private fun CrossFadeState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
markAsRead: MutableState<Boolean>
) {
val feedState by viewModel.feedContent.collectAsState()
Box(Modifier.pullRefresh(pullRefreshState)) {
Column() {
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refreshing = true
}
}
is FeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(state, accountViewModel, nav, markAsRead)
}
FeedState.Loading -> {
LoadingFeed()
}
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
FeedLoaded(state, accountViewModel, nav, markAsRead)
}
FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@@ -120,7 +107,7 @@ private fun FeedLoaded(
state.feed.value,
key = { index, item -> if (index == 0) index else item.idHex }
) { _, item ->
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) {
Row(Modifier.fillMaxWidth()) {
ChatroomCompose(
item,
accountViewModel = accountViewModel,
@@ -35,6 +35,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(
@@ -61,10 +63,12 @@ fun RefresheableView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
val modifier = remember {
if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
}
Box(modifier) {
@@ -73,7 +77,13 @@ fun RefresheableView(
}
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = remember {
Modifier.align(Alignment.TopCenter)
}
)
}
}
}
@@ -167,7 +177,7 @@ private fun WatchScrollToTop(
}
}
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
@@ -188,21 +198,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
)
}
println("AAA Rendering $elapsed - ${item.event?.content()?.take(10)}")
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -80,11 +81,38 @@ class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFil
}
}
class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileNewThreadsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileNewThreadsFeedViewModel>): NostrUserProfileNewThreadsFeedViewModel {
return NostrUserProfileNewThreadsFeedViewModel(user, account) as NostrUserProfileNewThreadsFeedViewModel
}
}
}
class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileConversationsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileConversationsFeedViewModel>): NostrUserProfileConversationsFeedViewModel {
return NostrUserProfileConversationsFeedViewModel(user, account) as NostrUserProfileConversationsFeedViewModel
}
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) {
class Factory(val user: User) : ViewModelProvider.Factory {
override fun <NostrUserProfileReportFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileReportFeedViewModel>): NostrUserProfileReportFeedViewModel {
return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel
}
}
}
class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileBookmarksFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileBookmarksFeedViewModel>): NostrUserProfileBookmarksFeedViewModel {
return NostrUserProfileBookmarksFeedViewModel(user, account) as NostrUserProfileBookmarksFeedViewModel
}
}
}
class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
@@ -257,6 +285,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
private var collectorJob: Job? = null
init {
Log.d("Init", "${this.javaClass.simpleName}")
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -84,6 +85,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
var collectorJob: Job? = null
init {
Log.d("Init", "${this.javaClass.simpleName}")
collectorJob = viewModelScope.launch(Dispatchers.IO) {
checkNotInMainThread()
@@ -98,6 +98,7 @@ class RelayFeedViewModel : ViewModel() {
fun RelayFeedView(
viewModel: RelayFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
@@ -107,7 +108,7 @@ fun RelayFeedView(
}
if (wantsToAddRelay.isNotEmpty()) {
NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay)
NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay, nav = nav)
}
var refreshing by remember { mutableStateOf(false) }
@@ -90,7 +90,10 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -120,17 +123,21 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
is FeedState.Loaded -> {
refreshing = false
LaunchedEffect(noteId) {
// waits to load the thread to scroll to item.
delay(100)
val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull()
var position = state.feed.value.indexOf(noteForPosition)
launch(Dispatchers.IO) {
// waits to load the thread to scroll to item.
delay(100)
val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull()
var position = state.feed.value.indexOf(noteForPosition)
if (position >= 0) {
if (position >= 1 && position < state.feed.value.size - 1) {
position-- // show the replying note
if (position >= 0) {
if (position >= 1 && position < state.feed.value.size - 1) {
position-- // show the replying note
}
withContext(Dispatchers.Main) {
listState.animateScrollToItem(position)
}
}
listState.animateScrollToItem(position)
}
}
@@ -288,7 +295,7 @@ fun NoteMaster(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
DisplayFollowingHashtagsInPost(noteEvent, accountViewModel, nav)
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
Text(
timeAgo(note.createdAt(), context = context),
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -104,6 +105,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
var collectorJob: Job? = null
init {
Log.d("Init", "${this.javaClass.simpleName}")
collectorJob = viewModelScope.launch(Dispatchers.IO) {
checkNotInMainThread()
@@ -21,6 +21,9 @@ import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -34,6 +37,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it }
fun isWriteable(): Boolean {
return account.isWriteable()
@@ -56,6 +60,11 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun isNoteHidden(note: Note): Boolean {
val isSensitive = note.event?.isSensitive() ?: false
return account.isHidden(note.author!!) || (isSensitive && account.showSensitiveContent == false)
}
fun hasReactedTo(baseNote: Note, reaction: String): Boolean {
return account.hasReacted(baseNote, reaction)
}
@@ -262,6 +271,29 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.defaultZapType
}
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newCanPreview = !note.hasAnyReports()
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(true, true, persistentSetOf())
} else {
val newRelevantReports = account.getRelevantReports(note)
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
}
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -14,10 +14,10 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -88,16 +88,17 @@ 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.ResizeImage
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
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -115,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) {
@@ -235,7 +236,7 @@ fun ChannelScreen(
)
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = DoubleVertSpacer)
replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
@@ -363,14 +364,18 @@ fun EditFieldRow(
onSendNewMessage()
},
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = Modifier.height(32.dp).padding(end = 10.dp)
modifier = Modifier
.height(32.dp)
.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.placeholderText,
modifier = Modifier.height(32.dp).padding(start = 2.dp)
modifier = Modifier
.height(32.dp)
.padding(start = 2.dp)
) {
val fileServer = if (isPrivate) {
// TODO: Make private servers
@@ -378,9 +383,11 @@ fun EditFieldRow(
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
}
} else {
@@ -477,6 +484,31 @@ fun MyTextField(
)
}
@Composable
fun ChannelHeader(
channelNote: Note,
showVideo: Boolean,
showBottomDiviser: Boolean,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelHex by remember {
derivedStateOf {
channelNote.channelHex()
}
}
channelHex?.let {
ChannelHeader(
channelHex = it,
showVideo = showVideo,
showBottomDiviser = showBottomDiviser,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun ChannelHeader(
channelHex: String,
@@ -486,12 +518,13 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var baseChannel by remember { mutableStateOf<Channel?>(LocalCache.channels[channelHex]) }
val scope = rememberCoroutineScope()
var baseChannel by remember { mutableStateOf(LocalCache.channels[channelHex]) }
LaunchedEffect(key1 = channelHex) {
scope.launch(Dispatchers.IO) {
baseChannel = LocalCache.checkGetOrCreateChannel(channelHex)
if (baseChannel == null) {
LaunchedEffect(key1 = channelHex) {
launch(Dispatchers.IO) {
baseChannel = LocalCache.checkGetOrCreateChannel(channelHex)
}
}
}
@@ -516,74 +549,103 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
Column(
Modifier.clickable {
nav("Channel/${baseChannel.idHex}")
}
) {
if (channel is LiveActivitiesChannel) {
val streamingUrl by remember(channelState) {
derivedStateOf {
channel.info?.streaming()
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
nav("Channel/${baseChannel.idHex}")
}
}
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically) {
VideoView(
videoUri = streamingUrl!!,
description = null
)
val streamingUrl by remember(channelState) {
derivedStateOf {
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
ZoomableContentView(
content = streamingUrl!!
)
}
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = ResizeImage(channel.profilePicture(), Size35dp),
contentDescription = context.getString(R.string.profile_image),
modifier = Modifier
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
/*
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
*/
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
channel.toBestDisplayName(),
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel.summary()}",
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
val summary = remember(channelState) {
channel.summary()?.ifBlank { null }
}
if (summary != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = summary,
color = MaterialTheme.colors.placeholderText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
Row(
modifier = Modifier
.height(Size35dp)
.padding(bottom = 3.dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
ChannelActionOptions(channel, accountViewModel, nav)
@@ -641,49 +703,88 @@ private fun LiveChannelActionOptions(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMe by remember(accountViewModel) {
val isLive by remember(channel) {
derivedStateOf {
channel.creator == accountViewModel.account.userProfile()
channel.info?.status() == "live"
}
}
val accountState by accountViewModel.accountLiveData.observeAsState()
val isFollowing by remember(accountState) {
derivedStateOf {
accountState?.account?.followingChannels?.contains(channel.idHex) ?: false
}
val note = remember(channel.idHex) {
LocalCache.getNoteIfExists(channel.idHex)
}
val status by remember {
derivedStateOf {
channel.info?.status()
}
}
if (isMe) {
// EditButton(accountViewModel, channel)
} else {
LocalCache.addressables[channel.idHex]?.let {
if (status == "live") {
Text(
text = "LIVE",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Red) }
.padding(horizontal = 5.dp)
)
Spacer(modifier = StdHorzSpacer)
}
LikeReaction(it, MaterialTheme.colors.onSurface, accountViewModel)
note?.let {
if (isLive) {
LiveFlag()
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
}
}
@Composable
fun LiveFlag() {
Text(
text = stringResource(id = R.string.live_stream_live_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Red) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun EndedFlag() {
Text(
text = stringResource(id = R.string.live_stream_ended_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun OfflineFlag() {
Text(
text = stringResource(id = R.string.live_stream_offline_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun ScheduledFlag() {
Text(
text = stringResource(id = R.string.live_stream_planned_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
private fun NoteCopyButton(
note: Channel
@@ -71,8 +71,6 @@ fun ChatroomListScreen(
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
@@ -49,13 +49,15 @@ fun ChatroomScreen(
) {
if (userId == null) return
var userRoom by remember { mutableStateOf<User?>(null) }
var userRoom by remember(userId) { mutableStateOf<User?>(null) }
LaunchedEffect(userId) {
launch(Dispatchers.IO) {
val newUser = LocalCache.checkGetOrCreateUser(userId)
if (newUser != userRoom) {
userRoom = newUser
if (userRoom == null) {
LaunchedEffect(userId) {
launch(Dispatchers.IO) {
val newUser = LocalCache.checkGetOrCreateUser(userId)
if (newUser != userRoom) {
userRoom = newUser
}
}
}
}
@@ -196,7 +198,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, nav: (Str
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size35dp
@@ -34,7 +34,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
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.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -65,7 +68,7 @@ fun HomeScreen(
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, liveActivitiesViewModel, accountViewModel)
if (wantsToAddNip47 != null) {
UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel)
@@ -73,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()
@@ -184,20 +189,40 @@ 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 ->
ChannelHeader(
channelHex = item.idHex,
showVideo = false,
showBottomDiviser = true,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
accountViewModel = accountViewModel,
nav = nav
)
CheckIfLiveActivityIsOnline(item) {
ChannelHeader(
channelHex = remember { item.idHex },
showVideo = false,
showBottomDiviser = true,
modifier = remember {
Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@Composable
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 = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
}
}
Crossfade(targetState = online) {
if (it) {
whenOnline()
}
}
}
@@ -206,22 +231,18 @@ private fun FeedLoaded(
fun WatchAccountForHomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val followState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList) {
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
LaunchedEffect(followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
liveActivitiesViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
}
@@ -173,7 +173,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
AppBottomBar(accountViewModel, navState, navBottomRow)
},
topBar = {
AppTopBar(followLists, navState, scaffoldState, accountViewModel)
AppTopBar(followLists, navState, scaffoldState, accountViewModel, nav = nav)
},
drawerContent = {
DrawerContent(nav, scaffoldState, sheetState, accountViewModel)
@@ -9,6 +9,7 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -189,6 +190,7 @@ fun SummaryBar(model: UserReactionsViewModel) {
)
}
@Stable
class LabelValueFormatter(val axisLabels: List<String>) : AxisValueFormatter<AxisPosition.Horizontal.Bottom> {
override fun formatValue(
value: Float,
@@ -198,6 +200,7 @@ class LabelValueFormatter(val axisLabels: List<String>) : AxisValueFormatter<Axi
}
}
@Stable
class CountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.Start> {
override fun formatValue(
value: Float,
@@ -207,6 +210,7 @@ class CountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.Start
}
}
@Stable
class AmountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.End> {
override fun formatValue(
value: Float,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -75,12 +76,9 @@ 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.UserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserAppRecommendationsFeedViewModel
@@ -103,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
@@ -112,10 +109,10 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(userId)) }
LaunchedEffect(userId) {
if (userBase == null) {
if (userBase == null) {
LaunchedEffect(userId) {
// waits to resolve.
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
if (newUserBase != userBase) {
userBase = newUserBase
@@ -165,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
@@ -223,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
@@ -241,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
)
}
}
}
@@ -301,21 +409,25 @@ 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)
5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
6 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
7 -> TabRelays(baseUser, accountViewModel, nav)
}
}
@@ -367,8 +479,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
@@ -510,7 +621,7 @@ private fun ProfileHeader(
) {
val clipboardManager = LocalClipboardManager.current
UserPicture(
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = 100.dp,
@@ -782,7 +893,7 @@ private fun DrawAdditionalInfo(
}
@Composable
private fun DisplayLNAddress(
fun DisplayLNAddress(
lud16: String?,
userHex: String,
account: Account
@@ -1040,39 +1151,45 @@ private fun WatchAndRenderBadgeImage(
) {
val noteState by baseNote.live().metadata.observeAsState()
val eventId = remember(noteState) { noteState?.note?.idHex } ?: return
val image = remember(noteState) {
val event = noteState?.note?.event as? BadgeDefinitionEvent
event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null }
val image by remember(noteState) {
derivedStateOf {
val event = noteState?.note?.event as? BadgeDefinitionEvent
event?.thumb()?.ifBlank { null } ?: event?.image()?.ifBlank { null }
}
}
val bgColor = MaterialTheme.colors.background
if (image == null) {
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.width(size)
.height(size)
.background(MaterialTheme.colors.background)
modifier = remember {
pictureModifier
.width(size)
.height(size)
.drawBehind { drawRect(bgColor) }
}
)
} else {
RobohashFallbackAsyncImage(
robot = eventId,
robotSize = size,
model = image,
model = image!!,
contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null) {
this.clickable(onClick = { onClick(eventId) })
} else {
this
modifier = remember {
pictureModifier
.width(size)
.height(size)
.clip(shape = CircleShape)
.drawBehind { drawRect(bgColor) }
.run {
if (onClick != null) {
this.clickable(onClick = { onClick(eventId) })
} else {
this
}
}
}
}
)
}
@@ -1119,9 +1236,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()
}
@@ -1142,9 +1257,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()
}
@@ -1165,9 +1278,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
}
@Composable
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
fun TabBookmarks(feedViewModel: NostrUserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
@@ -1257,9 +1368,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()) {
@@ -1287,7 +1396,7 @@ private fun WatchReportsAndUpdateFeed(
}
@Composable
fun TabRelays(user: User, accountViewModel: AccountViewModel) {
fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: RelayFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -1316,7 +1425,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false, nav = nav)
}
}
}
@@ -62,9 +62,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ChannelName
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.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
@@ -366,6 +366,10 @@ private fun DisplaySearchResults(
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val notes by searchBarViewModel.searchResultsNotes.collectAsState()
val hasNewMessages = remember {
mutableStateOf(false)
}
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@@ -405,7 +409,7 @@ private fun DisplaySearchResults(
},
channelLastTime = null,
channelLastContent = item.summary(),
false,
hasNewMessages = hasNewMessages,
onClick = { nav("Channel/${item.idHex}") }
)
}
@@ -493,7 +497,7 @@ fun UserLine(
top = 10.dp
)
) {
UserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
Column(
modifier = Modifier
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -319,7 +320,7 @@ private fun RenderVideoOrPicture(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 2.dp)
) {
RelayBadges(baseNote = note)
RelayBadges(baseNote = note, accountViewModel, nav)
}
}
}
@@ -354,7 +355,7 @@ private fun VideoUserOptionAction(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayBadges(baseNote: Note) {
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = remember(noteRelaysState) {
noteRelaysState?.note?.relays ?: emptySet()
@@ -362,7 +363,7 @@ private fun RelayBadges(baseNote: Note) {
FlowRow() {
noteRelays.forEach { dirtyUrl ->
RenderRelay(dirtyUrl)
RenderRelay(dirtyUrl, accountViewModel, nav)
}
}
}
@@ -397,7 +398,7 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
}*/
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = Size35dp, 28.sp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp)
ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedOff
import android.content.Context
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -35,11 +36,11 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import java.util.*
@@ -272,6 +273,7 @@ fun LoginPage(
try {
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt())
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
errorMessage = context.getString(R.string.invalid_key)
}
}
@@ -305,7 +307,7 @@ fun LoginPage(
}
},
style = TextStyle(
fontSize = 14.sp,
fontSize = Font14SP,
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center
@@ -16,6 +16,8 @@ val Nip05 = Color(0xFF01BAFF)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
val WarningColor = Color(0xFFC62828)
val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })

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