Compare commits

..
206 Commits
Author SHA1 Message Date
Vitor Pamplona 605d83e530 v0.63.0 2023-06-29 12:32:45 -04:00
Vitor Pamplona 5f710b7304 Adds #tunestr icon 2023-06-29 12:29:08 -04:00
Vitor Pamplona 1c6593fb17 Adds alt-text to screenshots 2023-06-29 12:15:03 -04:00
Vitor Pamplona 33fe34d3b3 updates screenshot titles 2023-06-29 12:13:56 -04:00
Vitor Pamplona e257fec92c Breaking screenshots in two lines 2023-06-29 12:10:26 -04:00
Vitor Pamplona 6a70a6c72f Showing screenshots in the homepage 2023-06-29 12:09:05 -04:00
Vitor Pamplona 270d6b411a Updating Amethyst Logo to Zitron's 2023-06-29 12:00:20 -04:00
Vitor PamplonaandGitHub 803b1e861e Merge pull request #480 from greenart7c3/main
added followed tags in profile screen
2023-06-29 10:03:33 -04:00
Vitor Pamplona 7300dc3d78 No need to point to the release section after the GitHub badge has been added. 2023-06-29 09:52:24 -04:00
Vitor Pamplona 405d64402b decreases height 2023-06-29 09:42:55 -04:00
Vitor Pamplona fe1bff1074 removes space between badges 2023-06-29 09:41:44 -04:00
Vitor Pamplona 2952ca1978 Reverting change 2023-06-29 09:39:14 -04:00
Vitor Pamplona 32dcd7747b slight change in borders 2023-06-29 09:38:20 -04:00
Vitor Pamplona eca5a1de15 Matching obtainium's and github's badge 2023-06-29 09:36:55 -04:00
Vitor Pamplona 886442ac52 trying to align obtainium's image on the readme 2023-06-29 09:35:20 -04:00
Vitor Pamplona 8521611008 Increasing borders a bit 2023-06-29 09:32:38 -04:00
Vitor Pamplona df647e4047 Fixing borders 2023-06-29 09:31:44 -04:00
Vitor Pamplona af26026d25 adds borders 2023-06-29 09:29:55 -04:00
Vitor Pamplona 43be55f3d6 reduces the size of the badge 2023-06-29 09:26:32 -04:00
Vitor Pamplona 9b3cb92cb3 Adds obtainium and APK to the links 2023-06-29 09:25:35 -04:00
Vitor Pamplona f2b0ce5fd0 adds obtainium image 2023-06-29 09:24:42 -04:00
Vitor Pamplona 787ca98daf v0.62.8 2023-06-28 19:06:05 -04:00
Vitor Pamplona 71517dc454 Formatting 2023-06-28 18:54:42 -04:00
Vitor Pamplona 949301d58f Mocking Looper to use the checkMainThread in tests 2023-06-28 18:54:33 -04:00
Vitor Pamplona cf8ac4faae Merge remote-tracking branch 'origin/HEAD'
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2023-06-28 18:32:59 -04:00
Vitor Pamplona ad3aa124fc Performance improvements additional MainThread checks. 2023-06-28 18:32:10 -04:00
Vitor Pamplona 2326aa9f7c Runs the translation update in the main thread 2023-06-28 14:52:01 -04:00
Vitor Pamplona acfd687d74 Avoids blinking the nip05 address 2023-06-28 14:51:34 -04:00
Vitor Pamplona 9b9183395d BugFix: Ignoring case when comparing original and translated versions. 2023-06-28 14:51:02 -04:00
greenart7c3andGitHub 45b4902b2b Merge branch 'vitorpamplona:main' into main 2023-06-28 15:40:36 -03:00
greenart7c3 6056d60d32 added followed tags in profile screen 2023-06-28 15:37:24 -03:00
Vitor PamplonaandGitHub 61af248c09 Merge pull request #479 from ZsZolee/main
Additional Hungarian translation
2023-06-28 11:17:16 -04:00
Vitor PamplonaandGitHub 5a580db1b8 Merge pull request #478 from greenart7c3/main
fix rendering url of highlight note
2023-06-28 11:17:03 -04:00
Vitor PamplonaandGitHub a72ae49e93 Merge pull request #477 from believethehype/cashuredeem
Add copy button to Cashu Rendering, better default error message
2023-06-28 11:16:08 -04:00
Vitor Pamplona 40a01219cc Moves RichText Modifiers to singleton classes. 2023-06-28 11:13:38 -04:00
Vitor Pamplona c652390a9c Updates URLPreview in the main thread. 2023-06-28 11:09:54 -04:00
Vitor Pamplona 0916a97526 Users rememberPagerState instead of just remember 2023-06-28 11:09:19 -04:00
Vitor Pamplona d76ef0a1ca updates firebase to the newest version 2023-06-28 11:06:56 -04:00
ZoltanandGitHub 3a70ce1587 Additional Hungarian translation 2023-06-28 16:52:39 +02:00
greenart7c3 9f96e84378 fix rendering url of highlight note 2023-06-28 07:58:19 -03:00
Believethehype 985771beba Add copy button to Cashu Rendering, better default error message 2023-06-28 11:49:14 +02:00
Vitor PamplonaandGitHub 3967633f77 Merge pull request #475 from davotoula/translation_updates
Updated se/de/cs translations
2023-06-27 15:05:31 -04:00
Vitor Pamplona ee5699c2de v0.62.7 2023-06-27 13:16:28 -04:00
Vitor Pamplona b8f4d9f0bb BugFix on zapraiser completed bar 2023-06-27 13:14:47 -04:00
Vitor Pamplona 8b1de47517 v0.62.6 2023-06-27 12:27:06 -04:00
Vitor Pamplona c6ff514ba2 Logs rendering time of sections of the post 2023-06-27 12:26:57 -04:00
Vitor Pamplona 50f2d18b61 Moves state assignments to the main thread. 2023-06-27 12:05:11 -04:00
Vitor Pamplona fa7ccdfc20 Reduces full screen updates to once a second. 2023-06-27 12:02:55 -04:00
David Kaspar fe02519737 Updated se/de/cs translations 2023-06-27 13:30:37 +01:00
Vitor Pamplona f2e9fbebea v0.62.5 2023-06-26 22:42:42 -04:00
Vitor Pamplona 52af109b4e Minor adjustments in threading 2023-06-26 20:38:06 -04:00
Vitor Pamplona 833e6bc3e1 Moves cashu processing to a state class to account for errors. 2023-06-26 20:17:35 -04:00
Vitor Pamplona 9bed6fdb9a v0.62.4 2023-06-26 18:22:07 -04:00
Vitor Pamplona a4a867ebcd Achieves a bigger space if the hide system bars is placed outside the Dialog. 2023-06-26 18:18:04 -04:00
Vitor Pamplona 4da2f18957 Video Dialog in full screen 2023-06-26 17:44:36 -04:00
Vitor Pamplona 4141a52d51 Allows video dialog on streaming 2023-06-26 15:40:13 -04:00
Vitor Pamplona 4dbc71d577 Removes padding from the live header 2023-06-26 15:39:32 -04:00
Vitor Pamplona 3b5d792562 Extracts Cashu logic into a service class. 2023-06-26 15:27:04 -04:00
Vitor Pamplona dd7f3225c6 Preloading channel from LocalCache 2023-06-26 14:59:18 -04:00
Vitor Pamplona 3477e7b8ba No need to remember Modifier 2023-06-26 14:55:55 -04:00
Vitor Pamplona a3782bb459 Remove image from the live event and use only the author picture in the header. 2023-06-26 14:47:22 -04:00
Vitor Pamplona d4003eee04 Adjusts Profile images to crop and not fit 2023-06-26 14:46:56 -04:00
Vitor Pamplona c9bbb66284 Adjusting logout message. 2023-06-26 14:33:21 -04:00
Vitor PamplonaandGitHub 1498fa9868 Merge pull request #474 from greenart7c3/main
add confirmation dialog before logout
2023-06-26 14:27:15 -04:00
Vitor PamplonaandGitHub 26b493aa1d Merge pull request #473 from believethehype/cashuredeem
Melt cashu tokens in Mint directly instead of using webservice
2023-06-26 14:26:44 -04:00
Vitor Pamplona f9d652f849 Performance Improvements 2023-06-26 14:25:46 -04:00
greenart7c3 30b0ee444f add confirmation dialog before logout 2023-06-26 14:38:44 -03:00
Believethehype bf8341ef58 Merge remote-tracking branch 'upstream/main' into cashuredeem 2023-06-26 18:19:01 +02:00
Believethehype f9f1b0d98e Melt cashu tokens in Mint directly instead of using webservice 2023-06-26 18:18:45 +02:00
Vitor Pamplona 743a4c9d87 Using crossfade to show live activities 2023-06-26 09:12:10 -04:00
Vitor PamplonaandGitHub 275baa7497 Merge pull request #472 from believethehype/cashuredeem
Parse and Show Sat Amount from Cashu tokens
2023-06-26 08:44:19 -04:00
Vitor Pamplona ac1fb879c3 Bugfix for not updating user picture and name after a message arrives 2023-06-26 08:39:46 -04:00
Vitor Pamplona 270ad56450 Aligns LIVE marker to the right in the feed. 2023-06-26 08:38:29 -04:00
Believethehype d155241efc Parse and Show Sat Amount from Cashu tokens 2023-06-26 14:32:08 +02:00
Vitor PamplonaandGitHub d61927cbee Merge pull request #471 from believethehype/cashuredeem
Parse Cashu Tokens and provide link to redeem
2023-06-26 06:50:56 -04:00
Vitor PamplonaandGitHub 996b68f9c6 Merge pull request #470 from Bardesss/patch-1
Update strings.xml (NL)
2023-06-26 06:49:19 -04:00
Believethehype f6913663d1 enable autopay, change parsing 2023-06-26 11:57:31 +02:00
Believethehype 58600e13a8 parse cashu tokens 2023-06-26 11:04:31 +02:00
BardesssandGitHub 54e15ad3cd Update strings.xml (NL)
Updated translation
2023-06-26 08:52:35 +00:00
Vitor Pamplona 777c652bf5 v0.62.3 (TEST) 2023-06-25 19:35:00 -04:00
Vitor Pamplona 7fb1473e21 There is no need to invalidate twice. 2023-06-25 19:34:51 -04:00
Vitor Pamplona 95db1ec84f Typo 2023-06-25 19:34:35 -04:00
Vitor Pamplona 7438973a20 Trying to improve rendering speed of chat messages. 2023-06-25 19:18:32 -04:00
Vitor Pamplona fe31855860 Minor performance improvements in reaction row 2023-06-25 19:18:11 -04:00
Vitor Pamplona d7bce4414d Crossfades changes in Reactions and Zaps 2023-06-25 19:17:48 -04:00
Vitor Pamplona b4a81c137c Removes the need for lifecycle awareness here. 2023-06-25 19:16:41 -04:00
Vitor Pamplona a6390f58d9 Removes debug info 2023-06-25 19:16:15 -04:00
Vitor Pamplona ca3f4c3ec6 Moves MaxWidth for Chat bubbles to Shapes 2023-06-25 16:49:33 -04:00
Vitor Pamplona 53bfd40a31 Simplifies hex check 2023-06-25 16:22:17 -04:00
Vitor Pamplona 798be25c43 BugFix for duplicated notes in the LocalCache 2023-06-25 16:21:36 -04:00
Vitor Pamplona ebe1bcedd0 Moves user msg Synchronization to it's own object. 2023-06-25 16:11:21 -04:00
Vitor Pamplona 4731575b87 Less memory intensive Hex Checker 2023-06-25 12:41:21 -04:00
Vitor Pamplona 383e761372 Moves LocalCache's Synchronized block to Concurrent implementations 2023-06-25 12:40:56 -04:00
Vitor Pamplona 54a95e6a4e Moves synchronized block to inner function 2023-06-25 12:38:08 -04:00
Vitor Pamplona 3105ade755 Moves Shape constants to the Shape class 2023-06-25 11:00:41 -04:00
Vitor Pamplona f651034303 Better starting point for the following observer 2023-06-24 18:08:11 -04:00
Vitor Pamplona 7554dc010c Fixes spacing from last change 2023-06-24 18:07:56 -04:00
Vitor Pamplona 70a962062b Minor code and performance improvements. 2023-06-24 18:03:04 -04:00
Vitor Pamplona 5cc7f3bf2b Correct Immutable class 2023-06-24 15:55:21 -04:00
Vitor Pamplona b040e0ca13 Moves OnlineChecker to a singleton class with cache. 2023-06-24 15:53:22 -04:00
Vitor Pamplona 775094356c Fixes new posts not showing up in the feed. 2023-06-24 10:49:34 -04:00
Vitor Pamplona 0771b179b5 Reducing the need to update the event for every two hours. 2023-06-24 10:36:42 -04:00
Vitor Pamplona 8af307e8bb Migrating the UserProfile page to the new ViewModel structure 2023-06-23 22:00:51 -04:00
Vitor Pamplona e6ec3f1ce4 - Adds picture of the User to the Stream header.
- BugFix for the Click on UserPicture event
2023-06-23 20:44:58 -04:00
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
Vitor Pamplona 244e3f3b29 v0.61.2 2023-06-20 17:13:24 -04:00
Vitor Pamplona 6a44d283f9 - Restructures Chat Message Rendering
- Adds Sensitive Content protections for chat renderings.
- Adjusts padding of the channel headers.
2023-06-20 16:53:36 -04:00
Vitor Pamplona 6addce3d20 Simplifying some of the chat rendering process 2023-06-20 15:16:29 -04:00
Vitor Pamplona 8aae101ef6 Adds live activities to the main feed. 2023-06-20 13:54:47 -04:00
Vitor Pamplona d1bbdef5c4 v0.61.1 2023-06-19 22:01:30 -04:00
Vitor Pamplona b78967f8be Screen diet 2023-06-19 21:58:12 -04:00
Vitor Pamplona 01a2f746d0 Adds space between the play button and the user's name in private messages 2023-06-19 21:10:01 -04:00
Vitor Pamplona d063c2990c Moves the label to the bottom of the video 2023-06-19 20:58:38 -04:00
Vitor Pamplona e31ee031cb Removes the cache from streaming files to make sure the stream keeps playing. 2023-06-19 20:53:38 -04:00
Vitor Pamplona 8fc489cc59 Formatting 2023-06-19 20:43:55 -04:00
Vitor Pamplona ffb2a19963 Adds Zaps and Reactions to the Zap Activity author 2023-06-19 20:43:48 -04:00
Vitor Pamplona b271c1775c BugFix for not updating Notifications quickly. 2023-06-19 20:30:13 -04:00
Vitor Pamplona f937e95b35 v0.61.0 2023-06-19 18:36:32 -04:00
Vitor Pamplona 1613e79860 Support for live chats in streaming events. 2023-06-19 18:34:52 -04:00
Vitor Pamplona dd40b4f205 Formatting 2023-06-19 16:24:58 -04:00
Vitor Pamplona 2ad9401ee6 Downloads last posts from the author. 2023-06-19 16:23:19 -04:00
Vitor Pamplona 69ade2f6e6 Merge remote-tracking branch 'origin/HEAD' 2023-06-19 16:02:23 -04:00
Vitor Pamplona 4006e62a82 Renders Channel headers, NIP94 and NIP95 in the thread head. 2023-06-19 16:01:51 -04:00
Vitor Pamplona ec88a8b157 Updates readme 2023-06-19 16:00:23 -04:00
Vitor PamplonaandGitHub 128211ecd2 Merge pull request #459 from greenart7c3/main
add toast message to show relay icon description
2023-06-19 15:32:46 -04:00
greenart7c3 c35b5e3703 add toast message for relay icons 2023-06-19 07:58:25 -03: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
196 changed files with 11066 additions and 3203 deletions
+20 -4
View File
@@ -4,6 +4,11 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
height="80">](https://github.com/ImranR98/Obtainium)
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
height="80">](https://github.com/vitorpamplona/amethyst/releases)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
@@ -11,15 +16,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Replies and mentions (NIP-10)
- [x] Reactions (NIP-25)
- [x] Reposts (NIP-18)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
@@ -56,6 +61,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
@@ -64,11 +70,15 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-102)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] 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)
@@ -184,6 +194,12 @@ Information that you share is publicly visible to anyone reading from relays tha
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
# Screenshots
| Home Feed | Messages | Live Streams | Notifications |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
object ServiceManager {
private var account: Account? = null
@@ -66,6 +67,7 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
@@ -103,7 +103,7 @@ class Account(
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun isWriteable(): Boolean {
@@ -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
@@ -597,6 +603,27 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = LiveActivitiesChatMessageEvent.create(
message = message,
activity = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
@@ -1107,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()
}
@@ -1201,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()
}
@@ -1213,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.
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -11,20 +13,96 @@ import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
@Stable
class Channel(val idHex: String) {
var creator: User? = null
class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info.about
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info?.summary()
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null }
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun summary(): String? {
return null
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun addNote(note: Note) {
@@ -39,23 +117,7 @@ class Channel(val idHex: String) {
notes.remove(noteHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
@@ -92,12 +154,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
}
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
@@ -17,10 +18,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
}
@Immutable
class HashtagIcon(
val icon: Int,
val description: String,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -11,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
@@ -40,19 +41,18 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
answer
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
}
fun getUserIfExists(key: String): User? {
return users[key]
if (key.isEmpty()) return null
return users.get(key)
}
fun getNoteIfExists(key: String): Note? {
@@ -82,14 +82,12 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
return notes.get(idHex) ?: run {
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
}
@@ -97,29 +95,32 @@ object LocalCache {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
}
val aTag = ATag.parse(key, null)
if (aTag != null) {
return getOrCreateChannel(aTag.toTag()) {
LiveActivitiesChannel(aTag)
}
}
return null
}
private fun isValidHexNpub(key: String): Boolean {
return try {
Hex.decode(key).toNpub()
true
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
false
}
if (key.isBlank()) return false
if (key.contains(":")) return false
return HexValidator.isHex(key)
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
channels.put(key, answer)
answer
val newObject = channelFactory(key)
channels.putIfAbsent(key, newObject) ?: newObject
}
}
@@ -137,16 +138,14 @@ object LocalCache {
}
}
@Synchronized
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
val answer = AddressableNote(key)
addressables.put(key.toTag(), answer)
answer
val newObject = AddressableNote(key)
addressables.putIfAbsent(key.toTag(), newObject) ?: newObject
}
}
@@ -233,7 +232,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
@@ -305,7 +304,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
@@ -333,6 +332,11 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
refreshObservers(note)
}
}
@@ -530,8 +534,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -572,8 +575,11 @@ object LocalCache {
}
deleteNote.channelHex()?.let {
val channel = checkGetOrCreateChannel(it)
channel?.removeNote(deleteNote)
channels[it]?.removeNote(deleteNote)
}
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
channels[it.toTag()]?.removeNote(deleteNote)
}
if (deleteNote.event is PrivateDmEvent) {
@@ -708,7 +714,9 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val oldChannel = getOrCreateChannel(event.id) {
PublicChatChannel(it)
}
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
@@ -723,7 +731,9 @@ object LocalCache {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
}
@@ -734,10 +744,13 @@ object LocalCache {
// new event
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -795,6 +808,47 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
val activityId = event.activity() ?: return
val channel = getOrCreateChannel(activityId.toTag()) {
LiveActivitiesChannel(activityId)
}
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelHideMessageEvent) {
}
@@ -1084,7 +1138,7 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
}
}
@@ -1172,6 +1226,7 @@ object LocalCache {
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
verifyAndConsume(it, relay)
@@ -1214,12 +1269,13 @@ object LocalCache {
}
}
@Stable
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
@@ -30,7 +30,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
override fun createdAt(): Long? {
if (event == null) return null
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
return minOf(publishedAt, lastCreatedAt)
}
}
@Stable
@@ -70,9 +77,20 @@ open class Note(val idHex: String) {
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
return if (event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
null
}
}
open fun address(): ATag? = null
@@ -373,7 +391,14 @@ open class Note(val idHex: String) {
}
fun isNewThread(): Boolean {
return event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0
return (
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
replyTo?.size == 0
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
@@ -481,9 +506,9 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
fun invalidateData() {
bundler.invalidate() {
// if (hasObservers()) {
postValue(NoteState(note))
// }
if (hasActiveObservers()) {
postValue(NoteState(note))
}
}
}
@@ -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)) {
@@ -49,6 +49,8 @@ object BlurHashDecoder {
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) {
return null
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
@@ -14,6 +15,7 @@ import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri
@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
)
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Reedem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},
onProgress = {
},
onError = onError
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = client.newCall(request).execute()
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
}
}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
@@ -18,7 +20,7 @@ class NIP30Parser {
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
@@ -31,7 +31,7 @@ class Nip05Verifier() {
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// checkNotInMainThread()
checkNotInMainThread()
val url = assembleUrl(nip05)
@@ -49,6 +49,8 @@ class Nip05Verifier() {
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
@@ -71,13 +73,15 @@ class Nip05Verifier() {
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
// checkNotInMainThread()
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400
)
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
@@ -113,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
}
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 50
)
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 200
)
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
}
@@ -70,6 +70,7 @@ abstract class NostrDataSource(val debugName: String) {
}
init {
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
@@ -5,6 +5,8 @@ import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -68,7 +70,9 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
),
authors = followSet,
limit = 400,
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
)
)
}
}
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
}
fun add(eventId: String) {
@@ -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")
}
}
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = taggedEvents()
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
@@ -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)
@@ -252,6 +252,7 @@ open class Event(
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -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())
}
}
}
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
private fun innerActivity() = tags.firstOrNull {
it.size > 3 && it[0] == "a" && it[3] == "root"
} ?: tags.firstOrNull {
it.size > 1 && it[0] == "a"
}
private fun activityHex() = innerActivity()?.let {
it.getOrNull(1)
}
fun activity() = innerActivity()?.let {
if (it.size > 1) {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
} else {
null
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
companion object {
const val kind = 1311
fun create(
message: String,
activity: ATag,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("a", activity.toTag(), "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, 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,18 +349,22 @@ private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
onClick: () -> Unit
) {
val hasNewMessages = remember {
mutableStateOf(false)
}
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
item.toBestDisplayName(),
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
channelLastContent = item.summary(),
hasNewMessages,
onClick = onClick
)
}
@@ -384,7 +390,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, 55.dp, accountViewModel)
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier = Modifier
@@ -25,14 +25,14 @@ 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.Channel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(accountViewModel.account, channel)
@@ -4,17 +4,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.PublicChatChannel
class NewChannelViewModel : ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
private var originalChannel: PublicChatChannel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?) {
fun load(account: Account, channel: PublicChatChannel?) {
this.account = account
if (channel != null) {
originalChannel = channel
@@ -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
@@ -723,7 +724,8 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
),
contentPadding = PaddingValues(0.dp)
) {
Text(text = stringResource(R.string.post), color = Color.White)
}
@@ -796,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
}
@@ -823,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))
)
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -143,7 +144,11 @@ open class NewPostViewModel() : ViewModel() {
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else if (originalNote?.channelHex() != null) {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
@@ -1,5 +1,10 @@
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
import androidx.compose.foundation.layout.PaddingValues
@@ -25,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
@@ -35,10 +41,12 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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.style.TextOverflow
@@ -48,21 +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(
@@ -108,6 +127,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
if (index == 0) {
ServerConfigHeader()
}
ServerConfig(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
@@ -119,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
)
}
}
@@ -157,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
)
@@ -167,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
)
@@ -177,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
)
@@ -187,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
)
@@ -203,6 +227,7 @@ fun ServerConfigHeader() {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ServerConfig(
item: RelaySetupInfo,
@@ -215,8 +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
) {
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,
@@ -240,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
)
@@ -252,15 +311,30 @@ fun ServerConfig(
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleFollows(item) }
modifier = Modifier
.size(30.dp),
onClick = { }
) {
Icon(
painterResource(R.drawable.ic_home),
stringResource(R.string.home_feed),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleFollows(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.home_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
Color.Green
} else {
@@ -272,14 +346,28 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePrivateDMs(item) }
onClick = { }
) {
Icon(
painterResource(R.drawable.ic_dm),
stringResource(R.string.private_message_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onTogglePrivateDMs(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.private_message_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
Color.Green
} else {
@@ -291,14 +379,28 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePublicChats(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Groups,
stringResource(R.string.public_chat_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onTogglePublicChats(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.public_chat_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
Color.Green
} else {
@@ -310,14 +412,28 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleGlobal(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Public,
stringResource(R.string.global_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleGlobal(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.global_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
Color.Green
} else {
@@ -337,7 +453,21 @@ fun ServerConfig(
stringResource(R.string.search_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleSearch(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
Color.Green
} else {
@@ -354,14 +484,28 @@ fun ServerConfig(
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Download,
null,
stringResource(R.string.read_from_relay),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.read) {
Color.Green
} else {
@@ -382,14 +526,28 @@ fun ServerConfig(
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
onClick = { }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
stringResource(R.string.write_to_relay),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.write) {
Color.Green
} else {
@@ -410,10 +568,24 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.SyncProblem,
null,
stringResource(R.string.errors),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
@@ -427,8 +599,24 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.DeleteSweep,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.spam),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
)
@@ -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,357 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.service.checkNotInMainThread
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) {
Crossfade(it) {
if (it != null) {
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) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay 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,197 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
LaunchedEffect(key1 = cashutoken) {
launch(Dispatchers.IO) {
val newCachuData = CashuProcessor().parse(cashutoken)
launch(Dispatchers.Main) {
cachuData = newCachuData
}
}
}
Crossfade(targetState = cachuData) {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> -> Text(
text = "$cashutoken ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
else -> {}
}
}
}
@Composable
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
val lud16 = remember(accountViewModel) {
accountViewModel.account.userProfile().info?.lud16
}
val useWebService = false
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.cashu),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = stringResource(R.string.cashu),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
token.totalAmount.let {
Text(
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Button(
modifier = Modifier
.padding(vertical = 10.dp).padding(horizontal = 2.dp),
onClick = {
if (lud16 != null) {
scope.launch(Dispatchers.IO) {
CashuProcessor().melt(
token,
lud16,
onSuccess = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
)
}
} else {
scope.launch {
Toast.makeText(
context,
"No Lightning Address set",
Toast.LENGTH_SHORT
).show()
}
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(
stringResource(R.string.cashu_redeem),
color = Color.White,
fontSize = 18.sp
)
}
Button(
modifier = Modifier
.padding(vertical = 10.dp).padding(horizontal = 1.dp),
onClick = {
if (useWebService) {
// In case we want to use the cashu.me webservice
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=false"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(context, intent, null)
} else {
// Copying the token to clipboard for now
var orignaltoken = token.token
clipboardManager.setText(AnnotatedString("$orignaltoken"))
scope.launch {
Toast.makeText(context, "Copied token to clipboard", Toast.LENGTH_SHORT).show()
}
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("", color = Color.White, fontSize = 18.sp)
}
}
}
}
}
@@ -92,8 +92,8 @@ private fun LoadNote(
) {
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
LaunchedEffect(key1 = hex) {
if (noteBase == null) {
if (noteBase == null) {
LaunchedEffect(key1 = hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
@@ -186,8 +186,8 @@ private fun DisplayAddress(
) {
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
if (noteBase == null) {
if (noteBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
@@ -225,8 +225,8 @@ private fun DisplayUser(
) {
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
if (userBase == null) {
if (userBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
@@ -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
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -25,13 +26,16 @@ fun MayBeWithdrawal(lnurlWord: String) {
}
}
lnWithdrawal?.let {
ClickableWithdrawal(withdrawalString = it)
Crossfade(targetState = lnWithdrawal) {
if (it != null) {
ClickableWithdrawal(withdrawalString = it)
} else {
Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
?: Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
@Immutable
sealed class GenericLoadable<T> {
@Immutable
class Loading<T> : GenericLoadable<T>()
@Immutable
class Loaded<T>(val loaded: T) : GenericLoadable<T>()
@Immutable
class Empty<T> : GenericLoadable<T>()
@Immutable
class Error<T>(val errorMessage: String) : GenericLoadable<T>()
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,7 +28,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import java.text.NumberFormat
@Composable
@@ -35,7 +36,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
@@ -50,13 +51,16 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
}
lnInvoice?.let {
InvoicePreview(it.first, it.second)
Crossfade(targetState = lnInvoice) {
if (it != null) {
InvoicePreview(it.first, it.second)
} else {
Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
?: Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
@@ -10,6 +10,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import java.io.File
@@ -24,6 +25,8 @@ class MediaCompressor {
onReady: (Uri, String?, Long?) -> Unit,
onError: (String) -> Unit
) {
checkNotInMainThread()
if (contentType?.startsWith("video", true) == true) {
VideoCompressor.start(
context = applicationContext, // => This is required
@@ -3,8 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
@@ -17,38 +16,32 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.HeadingStyle
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -89,38 +82,6 @@ fun isValidURL(url: String?): Boolean {
}
}
internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle ->
when (level) {
0 -> textStyle.copy(
fontSize = 30.sp,
fontWeight = FontWeight.Light
)
1 -> textStyle.copy(
fontSize = 26.sp,
fontWeight = FontWeight.Light
)
2 -> textStyle.copy(
fontSize = 22.sp,
fontWeight = FontWeight.Light
)
3 -> textStyle.copy(
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
4 -> textStyle.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
5 -> textStyle.copy(
fontWeight = FontWeight.Bold
)
else -> textStyle
}
}
internal val DefaultParagraphSpacing: TextUnit = 12.sp
val richTextDefaults = RichTextStyle().resolveDefaults()
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
content.startsWith("# ") ||
@@ -159,7 +120,6 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
@@ -262,7 +222,7 @@ private fun parseUrls(
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
@@ -286,6 +246,8 @@ private fun RenderWord(
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (word.startsWith("cashuA", true)) {
WordType.CASHU
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -341,6 +303,7 @@ private fun RenderWordWithoutPreview(
WordType.INVOICE -> NormalWord(wordSpace)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.CASHU -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
@@ -371,6 +334,7 @@ private fun RenderWordWithPreview(
WordType.EMOJI -> RenderCustomEmoji(word, state)
WordType.INVOICE -> MayBeInvoicePreview(word)
WordType.WITHDRAW -> MayBeWithdrawal(word)
WordType.CASHU -> CashuPreview(word, accountViewModel)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
@@ -396,6 +360,9 @@ private fun NormalWord(word: String) {
)
}
@Immutable
data class UrlWithExtraChars(val url: String, val extraChars: String)
@Composable
private fun NoProtocolUrlRenderer(word: String) {
val wordSpace = remember(word) {
@@ -403,7 +370,7 @@ private fun NoProtocolUrlRenderer(word: String) {
}
var linkedUrl by remember(word) {
mutableStateOf<Pair<String, String>?>(null)
mutableStateOf<UrlWithExtraChars?>(null)
}
LaunchedEffect(key1 = word) {
@@ -413,19 +380,30 @@ private fun NoProtocolUrlRenderer(word: String) {
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
linkedUrl = Pair(url, additionalChars)
launch(Dispatchers.Main) {
linkedUrl = UrlWithExtraChars(url, additionalChars)
}
}
}
}
if (linkedUrl != null) {
ClickableUrl(linkedUrl!!.first, "https://${linkedUrl!!.first}")
Text("${linkedUrl!!.second} ")
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Crossfade(targetState = linkedUrl) {
if (it != null) {
RenderUrl(it)
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@Composable
private fun RenderUrl(it: UrlWithExtraChars) {
Row() {
ClickableUrl(it.url, "https://${it.url}")
Text("${it.extraChars} ")
}
}
@@ -439,56 +417,22 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
paragraphSpacing = DefaultParagraphSpacing,
headingStyle = DefaultHeadingStyle,
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor.value)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor.value)
)
)
)
val uri = LocalUriHandler.current
val onClick = { link: String ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
val onClick = remember {
{ link: String ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
}
Unit
}
Unit
}
ProvideTextStyle(TextStyle(lineHeight = 1.30.em)) {
ProvideTextStyle(MarkdownTextStyle) {
MaterialRichText(
style = myMarkDownStyle
style = MaterialTheme.colors.markdownStyle
) {
RefreshableContent(content, tags) {
Markdown(
@@ -550,12 +494,12 @@ private fun ObserveNIP19Event(
) {
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseNote == null) {
if (baseNote == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
launch(Dispatchers.Main) { baseNote = note }
}
}
}
@@ -587,12 +531,12 @@ private fun ObserveNIP19User(
) {
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseUser == null) {
if (baseUser == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
launch(Dispatchers.Main) { baseUser = user }
}
}
}
@@ -785,26 +729,34 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
}
loadedLink = LoadedBechLink(returningNote, it)
val newLink = LoadedBechLink(returningNote, it)
launch(Dispatchers.Main) {
loadedLink = newLink
}
}
}
}
if (canPreview) {
loadedLink?.let { loadedLink ->
loadedLink.baseNote?.let {
DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink)
} ?: run {
ClickableRoute(loadedLink.nip19, nav)
Crossfade(targetState = loadedLink) {
if (canPreview && it?.baseNote != null) {
Row() {
DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it)
}
} ?: run {
Text(text = remember { "$word " })
}
} else {
loadedLink?.let {
ClickableRoute(it.nip19, nav)
} ?: run {
Text(text = remember { "$word " })
} else if (it?.nip19 != null) {
Row() {
ClickableRoute(it.nip19, nav)
}
} else {
val text = remember {
if (word.length > 16) {
word.replaceRange(8, word.length - 8, ":")
} else {
"$word "
}
}
Text(text = text, maxLines = 1)
}
}
}
@@ -817,24 +769,10 @@ private fun DisplayFullNote(
nav: (String) -> Unit,
loadedLink: LoadedBechLink
) {
val borderColor = MaterialTheme.colors.subtleBorder
val modifier = remember {
Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
borderColor,
QuoteBorder
)
}
NoteCompose(
baseNote = it,
accountViewModel = accountViewModel,
modifier = modifier,
modifier = MaterialTheme.colors.replyModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
@@ -855,9 +793,11 @@ private fun DisplayFullNote(
}
}
data class HashWordWithExtra(val hashtag: String, val extras: String?)
@Composable
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
var tagSuffixPair by remember { mutableStateOf<HashWordWithExtra?>(null) }
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
@@ -872,66 +812,82 @@ fun HashTag(word: String, nav: (String) -> Unit) {
}
if (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
launch(Dispatchers.Main) {
tagSuffixPair = HashWordWithExtra(myTag, mySuffix)
}
}
}
}
tagSuffixPair?.let { tagPair ->
val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) }
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#${tagPair.first}")
}
},
onClick = { nav("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
Crossfade(targetState = tagSuffixPair) {
if (it != null) {
Row() {
RenderHashtag(it, nav)
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
} else {
Text(text = remember { "$word " })
}
tagPair.second?.ifBlank { "" }?.let {
Text(text = "$it ")
}
} ?: Text(text = "$word ")
}
}
@Composable
private fun RenderHashtag(
tagPair: HashWordWithExtra,
nav: (String) -> Unit
) {
val primary = MaterialTheme.colors.primary
val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag, primary) }
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#${tagPair.hashtag}")
}
},
onClick = { nav("Hashtag/${tagPair.hashtag}") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = mapOf(
myId to InlineIcon(hashtagIcon)
)
)
}
tagPair.extras?.ifBlank { "" }?.let {
Text(text = "$it ")
}
}
@Composable
private fun InlineIcon(hashtagIcon: HashtagIcon) =
InlineTextContent(
Placeholder(
width = Font17SP,
height = Font17SP,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
@@ -956,11 +912,15 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
loadedTag = LoadedTag(it, null, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(it, null, suffix)
}
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
loadedTag = LoadedTag(null, it, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(null, it, suffix)
}
}
}
}
@@ -969,19 +929,28 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
}
}
if (loadedTag == null) {
Text(
text = remember {
"$word "
Crossfade(targetState = loadedTag) {
if (it == null) {
Text(
text = remember {
"$word "
}
)
} else if (it.user != null) {
Row() {
DisplayUserFromTag(it.user, loadedTag?.addedChars ?: "", nav)
}
} else if (it.note != null) {
Row() {
DisplayNoteFromTag(
it.note,
loadedTag?.addedChars ?: "",
canPreview,
accountViewModel,
backgroundColor,
nav
)
}
)
} else {
loadedTag?.user?.let {
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
}
loadedTag?.note?.let {
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
}
}
}
@@ -999,15 +968,7 @@ private fun DisplayNoteFromTag(
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
),
modifier = MaterialTheme.colors.innerPostModifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
@@ -1027,22 +988,27 @@ private fun DisplayUserFromTag(
addedChars: String,
nav: (String) -> Unit
) {
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${baseUser.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
val route = remember { "User/${baseUser.pubkeyHex}" }
val suffix = remember { "$addedChars " }
val hex = remember { baseUser.pubkeyDisplayHex() }
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "$addedChars " },
tags = userTags,
route = route,
nav = nav
)
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
Crossfade(targetState = meta) {
Row() {
val displayName = remember(it) {
it?.bestDisplayName() ?: hex
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = it?.tags
)
}
}
}
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil.ImageLoader
@@ -11,7 +13,6 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.Buffer
import java.security.MessageDigest
@@ -32,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])
@@ -59,9 +62,9 @@ private fun svgString(msg: String): String {
</svg>"""
}
@Stable
class HashImageFetcher(
private val context: Context,
private val size: Size,
private val data: Uri
) : Fetcher {
@@ -81,21 +84,21 @@ class HashImageFetcher(
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, options.size, data)
return HashImageFetcher(options.context, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
fun imageRequest(context: Context, message: String): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.build()
}
}
@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,18 +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.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
import java.util.Date
@Composable
fun RobohashAsyncImage(
robot: String,
robotSize: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -33,18 +28,9 @@ fun RobohashAsyncImage(
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val context = LocalContext.current
val size = with(LocalDensity.current) {
remember {
robotSize.roundToPx()
}
}
val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest(
context,
robot,
Size(size, size)
)
val imageRequest = remember(robot) {
Robohash.imageRequest(context, robot)
}
AsyncImage(
@@ -61,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,
@@ -76,55 +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 = with(LocalDensity.current) {
rememberAsyncImagePainter(
model = Robohash.imageRequest(
context,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
)
)
}
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,
@@ -133,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,11 +21,13 @@ fun UrlPreview(url: String, urlText: String) {
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) {
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
launch(Dispatchers.Main) {
urlPreviewState = it
}
}
}
}
@@ -52,13 +52,18 @@ import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DataSource
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
public var DefaultMutedSetting = mutableStateOf(true)
@@ -94,12 +99,26 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun VideoView(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@Composable
fun VideoView1(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
@@ -124,6 +143,7 @@ fun VideoView(
}
}
@OptIn(ExperimentalTime::class)
@Composable
fun VideoView(
videoUri: String,
@@ -132,6 +152,21 @@ fun VideoView(
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@Composable
fun VideoView1(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
@@ -144,8 +179,10 @@ fun VideoView(
if (videoUri.startsWith("file")) {
setMediaItem(media)
} else if (videoUri.endsWith("m3u8")) {
// Should not use cache.
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
setMediaSource(
HlsMediaSource.Factory(VideoCache.get()).createMediaSource(
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
media
)
)
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
@@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@@ -33,12 +33,10 @@ fun ZapRaiserRequest(
newPostViewModel: NewPostViewModel
) {
Column(
modifier = Modifier
.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -49,7 +47,7 @@ fun ZapRaiserRequest(
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = Color.Unspecified
)
@@ -1,20 +1,20 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,9 +22,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -37,6 +39,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -51,6 +54,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
@@ -73,9 +77,8 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -175,14 +178,7 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
mutableStateOf(false)
}
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
var mainImageModifier = MaterialTheme.colors.imageModifier
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
@@ -448,13 +444,34 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
val view = LocalView.current
DisposableEffect(key1 = Unit) {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.hide(
android.view.WindowInsets.Type.systemBars()
)
}
onDispose {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.show(
android.view.WindowInsets.Type.systemBars()
)
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
val pagerState: PagerState = remember { PagerState() }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val pagerState: PagerState = rememberPagerState()
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
@@ -463,6 +480,18 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
Row(
modifier = Modifier
.padding(10.dp)
@@ -479,18 +508,6 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
@@ -34,9 +34,9 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val publicChannels = account.followingChannels().map { it ->
it.notes.values
.filter { account.isAcceptable(it) }
.filter { account.isAcceptable(it) && it.event != null }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
.lastOrNull()
}
return (privateMessages + publicChannels)
@@ -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
}
@@ -3,6 +3,8 @@ 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.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -24,13 +26,16 @@ 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 {
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && 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()
@@ -0,0 +1,54 @@
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.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
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> {
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
checkNotInMainThread()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(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
}
.toSet()
}
override fun limit() = 2
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -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,
@@ -101,7 +101,7 @@ private fun RenderBottomMenu(
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column() {
Column(modifier = Modifier.height(50.dp)) {
Divider(
thickness = 0.25.dp
)
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -38,6 +39,7 @@ import kotlinx.coroutines.launch
fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
@@ -67,6 +69,7 @@ fun AppNavigation(
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
liveActivitiesViewModel = liveActivitiesViewModel,
accountViewModel = accountViewModel,
nav = nav,
nip47 = nip47
@@ -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,27 +113,30 @@ 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)
}
}
@Composable
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
fun NoTopBar() {
}
val list by remember(accountState) {
derivedStateOf {
accountState?.account?.defaultStoriesFollowList ?: GLOBAL_FOLLOWS
}
}
@Composable
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,
@@ -145,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,
@@ -166,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,
@@ -187,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 {
@@ -203,10 +199,10 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
}
Column() {
Column(modifier = Modifier.height(50.dp)) {
TopAppBar(
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.surface,
@@ -336,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,
@@ -418,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)) {
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -18,6 +20,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -39,17 +42,27 @@ 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.ChatHeadlineBorders
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -60,30 +73,73 @@ 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) { baseUser ->
Crossfade(baseUser) { user ->
if (user != null) {
UserRoomCompose(baseNote, user, accountViewModel, nav)
} else {
Box(Modifier.height(Size75dp).fillMaxWidth()) {
// Makes sure just a max amount of objects are loaded.
}
}
}
}
}
}
@Composable
private fun ChatroomChannel(
channelHex: HexKey?,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
}
}
@Composable
@@ -108,7 +164,7 @@ private fun ChannelRoomCompose(
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
channel.toBestDisplayName()
}
}
@@ -126,11 +182,11 @@ private fun ChannelRoomCompose(
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
if (hasNewMessages.value != newHasNewMessages) {
hasNewMessages.value = newHasNewMessages
}
}
@@ -138,29 +194,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 +203,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 +243,41 @@ private fun UserRoomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
val createAt by remember(note) {
derivedStateOf {
note.createdAt()
}
}
val content by remember(note) {
derivedStateOf {
accountViewModel.decrypt(note)
}
}
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
if (hasNewMessages.value != newHasNewMessages) {
hasNewMessages.value = newHasNewMessages
}
}
ChannelName(
channelPicture = {
UserPicture(
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) }
)
@@ -224,22 +303,20 @@ private fun WatchNotificationChanges(
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
var user by remember(baseUserHex) {
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
}
LaunchedEffect(key1 = baseUserHex) {
if (user == null) {
if (user == null) {
LaunchedEffect(key1 = baseUserHex) {
launch(Dispatchers.IO) {
user = LocalCache.checkGetOrCreateUser(baseUserHex)
}
}
}
user?.let {
content(it)
}
content(user)
}
@Composable
@@ -249,19 +326,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 +357,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)) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
channelPicture()
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
Row(modifier = ChatHeadlineBorders) {
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)
verticalAlignment = Alignment.CenterVertically
) {
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) })
}
Spacer(modifier = Height4dpModifier)
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),
thickness = 0.25.dp
modifier = StdTopPadding,
thickness = DividerThickness
)
}
}
@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 +465,7 @@ fun NewItemsBubble() {
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.Center)
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -38,33 +38,49 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@Composable
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { multiSetCard.note }
@@ -89,7 +105,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
launch(Dispatchers.Main) {
backgroundColor.value = newBackgroundColor
}
}
}
}
@@ -116,32 +134,39 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
Column(modifier = columnModifier) {
Galeries(multiSetCard, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
}
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
Row(remember { Modifier.fillMaxWidth() }) {
Spacer(modifier = remember { Modifier.width(65.dp) })
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
val (value, elapsed) = measureTimedValue {
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun Galeries(
multiSetCard: MultiSetCard,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
@@ -152,17 +177,26 @@ private fun Galeries(
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
if (hasZapEvents) {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
if (hasBoostEvents) {
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
RenderBoostGallery(boostEvents, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
if (hasLikeEvents) {
likeEvents.forEach {
RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel)
val (value, elapsed) = measureTimedValue {
likeEvents.forEach {
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
}
}
Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
}
@@ -170,39 +204,23 @@ private fun Galeries(
fun RenderLikeGallery(
reactionType: String,
likeEvents: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val isNotEmpty = remember(likeEvents) {
likeEvents.isNotEmpty()
}
val shortReaction by remember {
derivedStateOf {
reactionType.take(2)
}
}
if (isNotEmpty) {
Row(remember { Modifier.fillMaxWidth() }) {
if (likeEvents.isNotEmpty()) {
Row(Modifier.fillMaxWidth()) {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(end = 5.dp)
}
modifier = NotificationIconModifier
) {
val modifier = remember {
Modifier
.align(Alignment.TopEnd)
Modifier.align(Alignment.TopEnd)
}
when (shortReaction) {
when (val shortReaction = reactionType.firstFullChar()) {
"+" -> Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = remember { modifier.size(18.dp) },
modifier = modifier.size(Size18dp),
tint = Color.Unspecified
)
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
@@ -210,7 +228,7 @@ fun RenderLikeGallery(
}
}
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
AuthorGallery(likeEvents, nav, accountViewModel)
}
}
}
@@ -232,21 +250,17 @@ fun RenderZapGallery(
@Composable
private fun ZapIcon() {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(0.dp)
}
modifier = WidthAuthorPictureModifier
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
tint = BitcoinOrange,
modifier = remember {
Modifier
.size(25.dp)
.align(Alignment.TopEnd)
}
modifier =
Modifier
.size(Size25dp)
.align(Alignment.TopEnd)
)
}
}
@@ -254,21 +268,14 @@ private fun ZapIcon() {
@Composable
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(
modifier = remember {
Modifier.fillMaxWidth()
}
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = remember {
Modifier
.width(55.dp)
.padding(end = 4.dp)
}
modifier = NotificationIconModifierSmaller
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
@@ -282,7 +289,7 @@ fun RenderBoostGallery(
)
}
AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel)
AuthorGallery(boostEvents, nav, accountViewModel)
}
}
@@ -294,15 +301,27 @@ fun AuthorGalleryZaps(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach {
AuthorPictureAndComment(it.request, it.response, backgroundColor, nav, accountViewModel)
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
}
}
}
}
@Composable
private fun ParseAuthorCommentAndAmount(
zap: CombinedZap,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
RenderState(state, backgroundColor, accountViewModel, nav)
}
}
@Immutable
data class ZapAmountCommentNotification(
val user: User?,
@@ -311,12 +330,11 @@ data class ZapAmountCommentNotification(
)
@Composable
private fun AuthorPictureAndComment(
private fun ParseAuthorCommentAndAmount(
zapRequest: Note,
zapEvent: Note?,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
onReady: @Composable (MutableState<ZapAmountCommentNotification>) -> Unit
) {
val content = remember {
mutableStateOf(
@@ -329,29 +347,44 @@ private fun AuthorPictureAndComment(
}
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.Default) {
launch(Dispatchers.IO) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content.value = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
val newState = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
content.value = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
val newState = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
launch(Dispatchers.Main) { content.value = newState }
}
}
}
}
}
onReady(content)
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Row(
modifier = Modifier.clickable {
nav("User/${content.value.user?.pubkeyHex}")
content.value.user?.let {
nav(routeFor(it))
}
},
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(
DisplayAuthorCommentAndAmount(
authorComment = content,
backgroundColor = backgroundColor,
nav = nav,
@@ -361,91 +394,88 @@ private fun AuthorPictureAndComment(
}
val amountBoxModifier = Modifier
.fillMaxSize()
.size(Size35dp)
.clip(shape = CircleShape)
val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth()
val simpleModifier = Modifier
val sizedModifier = Modifier.size(Size35dp)
val textBoxModifier = Modifier
.padding(start = 5.dp)
.fillMaxWidth()
val bottomPadding1dp = Modifier.padding(bottom = 1.dp)
val commentTextSize = 12.sp
@Composable
private fun AuthorPictureAndComment(
private fun DisplayAuthorCommentAndAmount(
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
DisplayPicture(authorComment, accountViewModel)
DisplayAmount(authorComment)
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
CrossfadeToDisplayPicture(authorComment, accountViewModel)
CrossfadeToDisplayAmount(authorComment)
}
DisplayComment(authorComment, backgroundColor, nav, accountViewModel)
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
}
@Composable
fun DisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
authorComment.value.user?.let {
FastNoteAuthorPicture(
author = it,
size = Size35dp,
accountViewModel = accountViewModel,
pictureModifier = simpleModifier
)
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
Crossfade(authorComment.value) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
}
}
@Composable
fun DisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
authorComment.value.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
Crossfade(authorComment.value, modifier = amountBoxModifier) {
it.amount?.let {
Box(
modifier = remember {
Modifier
.fillMaxWidth()
.drawBehind { drawRect(backgroundColor) }
},
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.drawBehind { drawRect(backgroundColor) }
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.secondaryVariant,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
}
}
}
@Composable
fun DisplayComment(
fun CrossfadeToDisplayComment(
authorComment: MutableState<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
authorComment.value.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
Crossfade(authorComment.value) {
it.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -453,14 +483,13 @@ fun DisplayComment(
@Composable
fun AuthorGallery(
authorNotes: ImmutableList<Note>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach { note ->
BoxedAuthor(note, backgroundColor, nav, accountViewModel)
BoxedAuthor(note, nav, accountViewModel)
}
}
}
@@ -469,84 +498,78 @@ fun AuthorGallery(
@Composable
private fun BoxedAuthor(
note: Note,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(sizedModifier) {
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
}
}
@Composable
private fun NotePictureAndComment(
baseNote: Note,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val author = remember(baseNote) {
mutableStateOf(
ZapAmountCommentNotification(
user = baseNote.author,
comment = null,
amount = null
)
)
}
val modifier = remember(baseNote) {
Modifier.clickable {
nav("User/${baseNote.author?.pubkeyHex}")
}
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel)
}
}
@Composable
fun FastNoteAuthorPicture(
author: User,
size: Dp,
pictureModifier: Modifier = Modifier,
accountViewModel: AccountViewModel
) {
var profilePicture by remember {
mutableStateOf(author.info?.picture)
}
val authorPubKey = remember {
author.pubkeyHex
}
WatchUserMetadata(author) {
if (it.picture != profilePicture) {
profilePicture = it.picture
}
}
UserPicture(
userHex = authorPubKey,
userPicture = profilePicture,
size = size,
modifier = pictureModifier,
accountViewModel = accountViewModel
)
}
@Composable
fun WatchUserMetadata(userBase: User, onMetadataChanges: (UserMetadata) -> Unit) {
val userState by userBase.live().metadata.observeAsState()
LaunchedEffect(key1 = userState) {
launch(Dispatchers.Default) {
userState?.user?.info?.let {
onMetadataChanges(it)
Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) {
WatchNoteAuthor(note) { targetAuthor ->
Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author ->
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(author, accountViewModel)
}
}
}
}
@Composable
fun WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
author: User?,
accountViewModel: AccountViewModel
) {
if (author != null) {
WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel)
} else {
DisplayBlankAuthor(Size35dp)
}
}
@Composable
fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
author: User,
accountViewModel: AccountViewModel
) {
WatchUserMetadata(author) { baseUserPicture ->
Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = userPicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colors.profile35dpModifier,
contentScale = ContentScale.Crop
)
}
}
WatchFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
Crossfade(targetState = isFollowing) {
if (it) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
FollowingIcon(Size10dp)
}
}
}
}
}
@Composable
private fun WatchNoteAuthor(
baseNote: Note,
onContent: @Composable (User?) -> Unit
) {
val author by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
onContent(author)
}
@Composable
private fun WatchUserMetadata(
author: User,
onNewMetadata: @Composable (String?) -> Unit
) {
val userProfile by author.live().metadata.map {
it.user.profilePicture()
}.distinctUntilChanged().observeAsState(author.profilePicture())
onNewMetadata(userProfile)
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -28,6 +29,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -52,8 +54,8 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): MutableSta
mutableStateOf(default)
}
LaunchedEffect(key1 = user.nip05) {
if (nip05Verified.value == null) {
if (nip05Verified.value == null) {
LaunchedEffect(key1 = user.nip05) {
launch(Dispatchers.IO) {
user.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
@@ -109,21 +111,16 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val isValidNIP05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()?.split("@")?.size == 2
}
}
val nip05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()
}
}
val nip05 by baseUser.live().metadata.map {
it.user.nip05()
}.observeAsState(baseUser.nip05())
if (isValidNIP05) {
nip05?.let {
DisplayNIP05Line(it, baseUser, columnModifier)
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
val isValid = it.split("@").size == 2
if (isValid) {
DisplayNIP05Line(it, baseUser, columnModifier)
}
}
}
}
@@ -131,9 +128,11 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
DisplayNIP05(nip05, nip05Verified)
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
Crossfade(targetState = nip05Verified) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayNIP05(nip05, it)
}
}
}
}
@@ -150,7 +149,7 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = AnnotatedString(user),
text = remember(nip05) { AnnotatedString(user) },
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -160,7 +159,7 @@ private fun DisplayNIP05(
NIP05VerifiedSymbol(nip05Verified)
ClickableText(
text = AnnotatedString(domain),
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
File diff suppressed because it is too large Load Diff
@@ -36,11 +36,10 @@ import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -112,8 +111,8 @@ private fun VerticalDivider(color: Color) =
@Composable
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
var popupExpanded = remember { mutableStateOf(false) }
val showPopup = remember(popupExpanded) { { popupExpanded.value = true } }
val popupExpanded = remember { mutableStateOf(false) }
val showPopup = remember { { popupExpanded.value = true } }
content(showPopup)
@@ -122,192 +121,241 @@ fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, c
@Composable
fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
var showDeleteAlertDialog by remember(note) { mutableStateOf(false) }
var showBlockAlertDialog by remember(note) { mutableStateOf(false) }
var showReportDialog by remember(note) { mutableStateOf(false) }
val showSelectTextDialog = remember { mutableStateOf(false) }
val showDeleteAlertDialog = remember { mutableStateOf(false) }
val showBlockAlertDialog = remember { mutableStateOf(false) }
val showReportDialog = remember { mutableStateOf(false) }
if (popupExpanded) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
RenderMainPopup(
accountViewModel,
note,
onDismiss,
showBlockAlertDialog,
showDeleteAlertDialog,
showReportDialog
)
}
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.secondaryButtonBackground
if (showSelectTextDialog.value) {
accountViewModel.decrypt(note)?.let {
SelectTextDialog(it) { showSelectTextDialog.value = false }
}
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
if (showDeleteAlertDialog.value) {
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog.value = false
onDismiss()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
if (showBlockAlertDialog.value) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog.value = false
onDismiss()
}
}
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
if (showReportDialog.value) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog.value = false
onDismiss()
}
}
}
@Composable
private fun RenderMainPopup(
accountViewModel: AccountViewModel,
note: Note,
onDismiss: () -> Unit,
showBlockAlertDialog: MutableState<Boolean>,
showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.secondaryButtonBackground
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog = true
}
}
}
}
Divider(
color = primaryLight,
modifier = Modifier
.fillMaxWidth()
.width(1.dp)
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestDialog) {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note)
)
putExtra(Intent.EXTRA_TITLE, context.getString(R.string.quick_action_share_browser_link))
}
val shareIntent = Intent.createChooser(sendIntent, context.getString(R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringResource(R.string.quick_action_copy_user_id)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.FormatQuote,
stringResource(R.string.quick_action_copy_note_id)
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
showReportDialog = true
NoteQuickActionItem(
Icons.Default.Block,
stringResource(R.string.quick_action_block)
) {
if (accountViewModel.hideBlockAlertDialog) {
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog.value = true
}
}
}
}
Divider(
color = primaryLight,
modifier = Modifier
.fillMaxWidth()
.width(1.dp)
)
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(
Icons.Default.Delete,
stringResource(R.string.quick_action_delete)
) {
if (accountViewModel.hideDeleteRequestDialog) {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog.value = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(
Icons.Default.PersonRemove,
stringResource(R.string.quick_action_unfollow)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(
Icons.Default.PersonAdd,
stringResource(R.string.quick_action_follow)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringResource(R.string.quick_action_share)
) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note)
)
putExtra(
Intent.EXTRA_TITLE,
context.getString(R.string.quick_action_share_browser_link)
)
}
val shareIntent = Intent.createChooser(
sendIntent,
context.getString(R.string.quick_action_share)
)
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(
Icons.Default.Report,
stringResource(R.string.quick_action_report)
) {
showReportDialog.value = true
}
}
}
}
}
}
if (showSelectTextDialog) {
accountViewModel.decrypt(note)?.let {
SelectTextDialog(it) { showSelectTextDialog = false }
}
}
if (showDeleteAlertDialog) {
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog = false
onDismiss()
}
}
if (showBlockAlertDialog) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog = false
onDismiss()
}
}
if (showReportDialog) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog = false
onDismiss()
}
}
}
@Composable
@@ -332,23 +380,30 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
}
@Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.delete(note)
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
}
onDismiss()
},
onClickDontShowAgain = {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
}
onDismiss()
},
onDismiss = onDismiss
)
}
@Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
@@ -394,7 +449,9 @@ private fun QuickActionAlertDialog(
},
buttons = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickDontShowAgain) {
@@ -20,7 +20,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -32,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -428,9 +428,12 @@ fun ZapVote(
// only show tallies after a user has zapped note
if (!pollViewModel.canZap()) {
val amountStr = remember(poolOption.zappedValue) {
showAmount(poolOption.zappedValue)
}
Text(
showAmount(poolOption.zappedValue),
fontSize = 14.sp,
text = amountStr,
fontSize = Font14SP,
color = MaterialTheme.colors.placeholderText,
modifier = modifier
)
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,10 @@ import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -37,9 +39,6 @@ fun RelayCompose(
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val context = LocalContext.current
Column() {
@@ -60,8 +59,14 @@ fun RelayCompose(
overflow = TextOverflow.Ellipsis
)
val lastTime by remember(relay.lastEvent) {
derivedStateOf {
timeAgo(relay.lastEvent, context = context)
}
}
Text(
timeAgo(relay.lastEvent, context = context),
text = lastTime,
maxLines = 1
)
}
@@ -75,11 +80,7 @@ fun RelayCompose(
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.activeRelays()?.none { it.url == relay.url } == true) {
AddRelayButton { onAddRelay() }
} else {
RemoveRelayButton { onRemoveRelay() }
}
RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay)
}
}
@@ -90,6 +91,26 @@ fun RelayCompose(
}
}
@Composable
private fun RelayOptions(
accountViewModel: AccountViewModel,
relay: RelayInfo,
onAddRelay: () -> Unit,
onRemoveRelay: () -> Unit
) {
val userState by accountViewModel.userRelays.observeAsState()
val isNotUsingRelay = remember(userState) {
accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true
}
if (isNotUsingRelay) {
AddRelayButton(onAddRelay)
} else {
RemoveRelayButton(onRemoveRelay)
}
}
@Composable
fun AddRelayButton(onClick: () -> Unit) {
Button(
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -21,6 +22,7 @@ import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
@@ -128,7 +130,6 @@ private fun ReplyInformation(
fun ReplyInformationChannel(
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
channelHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -136,76 +137,40 @@ fun ReplyInformationChannel(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
sortedMentions = mentions
val newSortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { accountViewModel.account.isFollowing(it) }
.toImmutableList()
.ifEmpty { null }
if (newSortedMentions != sortedMentions) {
sortedMentions = newSortedMentions
}
}
}
if (sortedMentions != null) {
LoadChannel(channelHex) { channel ->
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
}
ReplyInformationChannel(
replyTo,
sortedMentions,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
}
)
Spacer(modifier = StdVertSpacer)
}
}
@Composable
fun ReplyInformationChannel(replyTo: ImmutableList<Note>?, mentions: ImmutableList<User>?, channel: Channel, nav: (String) -> Unit) {
ReplyInformationChannel(
replyTo,
mentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformationChannel(
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<User>?,
baseChannel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
onChannelTagClick: (Channel) -> Unit
onUserTagClick: (User) -> Unit
) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val channelName = remember(channelState) {
AnnotatedString("${channel.info.name} ")
}
FlowRow() {
Text(
stringResource(R.string.in_channel),
fontSize = 13.sp,
color = MaterialTheme.colors.placeholderText
)
ClickableText(
text = channelName,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.lessImportantLink, fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
@@ -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,9 @@ 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
import androidx.compose.material.MaterialTheme
@@ -9,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
@@ -18,34 +22,48 @@ 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
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
@@ -56,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" },
@@ -70,35 +138,8 @@ private fun UserNameDisplay(
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
} else if (bestDisplayName != null) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
DrawPlayName(bestDisplayName)
} else if (bestUserName != null) {
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
DrawPlayName(bestUserName)
} else {
Text(
npubDisplay,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
}
@@ -107,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 {

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