Compare commits

..
138 Commits
Author SHA1 Message Date
Vitor Pamplona 8f53537ebc v0.60.2 2023-06-19 14:43:34 -04:00
Vitor Pamplona 58c93e0cce - Redesign feed invalidations to account for changes in the follow list
- Redesign scroll to the top implementation to avoid using arguments in the navigator.
2023-06-19 14:30:15 -04:00
Vitor Pamplona be8848fb1b Restructures Stable elements for minor performance gains. 2023-06-18 18:57:21 -04:00
Vitor Pamplona a3efd15b95 v0.60.1 2023-06-18 14:43:59 -04:00
Vitor Pamplona a1930c232b Formatting 2023-06-18 14:42:26 -04:00
Vitor Pamplona 58a4fb18ae BugFixes for NPE 2023-06-18 14:40:46 -04:00
Vitor Pamplona be4870da1a Support for Per-post Zap-raisers. 2023-06-18 14:24:52 -04:00
Vitor Pamplona d2f6317d5c v0.60.0 2023-06-17 21:09:11 -04:00
Vitor Pamplona 05fb408842 - Adds Support for Kind 16
- Migrates citations to `e` when they were supposed to be `a`
- Removes original tags from Reposts
-
2023-06-17 21:08:33 -04:00
Vitor Pamplona 611305a406 Basic Support for live activities 2023-06-17 18:37:34 -04:00
Vitor Pamplona 5b856af19b - Activates support for m3u8 streaming
- BugFix on restarting the video when pressing mute.
2023-06-17 18:07:06 -04:00
Vitor Pamplona ce5106684f Moves the last seen time saved per route to an account property in order to speed up loading and facilitate multithreading in the app. 2023-06-17 17:28:14 -04:00
Vitor Pamplona f0fe2dfc17 Don't move the feed up when new follow list is updated 2023-06-17 15:56:46 -04:00
Vitor Pamplona fb5fad5d02 Fixing background color for messages when it's considered a new notification. 2023-06-17 10:53:37 -04:00
Vitor Pamplona 6314e3fcb2 Formatting 2023-06-16 19:59:12 -04:00
Vitor Pamplona da62760fd7 Moving static sizes to the Shape class 2023-06-16 19:57:25 -04:00
Vitor Pamplona e3eae80d4c Increasing performance of the follow/follower count 2023-06-16 19:11:14 -04:00
Vitor Pamplona 3cd0828cba Bugfix for home feed scrolling to the top when a new follower updates the cache. 2023-06-16 18:47:10 -04:00
Vitor Pamplona 806b86f950 Testing a release build script with deamon 2023-06-16 16:36:20 -04:00
Vitor Pamplona c931e2cbee v0.59.1 2023-06-16 16:21:23 -04:00
Vitor Pamplona 4e57eed5d8 Adds Name Playback on messages as well. 2023-06-16 16:18:06 -04:00
Vitor Pamplona 8e725259eb BugFix for Messages being marked as read from Notifications. 2023-06-16 16:17:42 -04:00
Vitor Pamplona b48d2df8d2 v0.59.0 2023-06-16 15:23:44 -04:00
Vitor Pamplona dbf34ae457 Text to speech for usernames 2023-06-16 15:22:44 -04:00
Vitor Pamplona 74be724835 Hide reposts of posts from people you already follow if the repost time is within the hour of the original post (not that far in the feed). 2023-06-16 14:31:52 -04:00
Vitor Pamplona 012c7a7105 BugFix for feed not updating after change in follows. 2023-06-16 14:30:22 -04:00
Vitor Pamplona e50dff3b62 BigFix for some reposts not showing up in the feed. 2023-06-16 14:15:53 -04:00
Vitor Pamplona ad5a4d0b59 v0.58.3 2023-06-15 18:51:39 -04:00
Vitor Pamplona 3116ea1ad9 Fixes reaction sizes and alignments. 2023-06-15 12:21:44 -04:00
Vitor Pamplona 72e474c9ec Bug fix for when deleting the reaction event reverting to the old like heart. 2023-06-15 09:37:09 -04:00
Vitor Pamplona fe9e85c282 v0.58.2 2023-06-14 20:58:51 -04:00
Vitor Pamplona 01ac90fbec BugFix for active like all the time 2023-06-14 20:55:46 -04:00
Vitor Pamplona dc079941e7 v0.58.1 2023-06-14 20:28:47 -04:00
Vitor Pamplona 9a7a94cb77 Cropping long reactions to two chars for emojis 2023-06-14 20:27:46 -04:00
Vitor Pamplona a44703cb49 v0.58.0 2023-06-14 16:50:54 -04:00
Vitor Pamplona 52bd6b4443 Custom reactions 2023-06-14 16:49:51 -04:00
Vitor Pamplona a9e1ce5aec Removing modifiers from remembers and into global singletons. 2023-06-14 14:36:10 -04:00
Vitor Pamplona 639fdb174d BugFix Add -> And 2023-06-14 14:32:55 -04:00
Vitor Pamplona 3cd02ee6cf Merge remote-tracking branch 'origin/HEAD' 2023-06-14 13:49:10 -04:00
Vitor Pamplona 52199fe281 Starts to use getXIfExists functions to preload objects 2023-06-14 13:48:58 -04:00
Vitor Pamplona 5616fa52d1 Simplifies URL Preview call 2023-06-14 13:48:35 -04:00
Vitor PamplonaandGitHub 7241c8bd9f Merge pull request #454 from ZsZolee/main
Small Hungarian translation
2023-06-14 10:55:16 -04:00
ZoltanandGitHub 991131f8cd Small Hungarian translation 2023-06-14 09:35:36 +02:00
Vitor Pamplona a24c95070c v0.57.0 2023-06-13 17:16:20 -04:00
Vitor Pamplona a346d31554 Finally adds markers to kind 1s 2023-06-13 17:08:50 -04:00
Vitor Pamplona 9b5c7ad23f - Moves zaps from a Map<Request,Response> to a List<CustomClass> to avoid recompositions due to the use of the unstable Pair object
- Creates the class ZapAmountCommentNotification to be used instead of the unstable Triple class.
2023-06-13 16:33:52 -04:00
Vitor Pamplona a01a060abc Uses animateItemPlacement to show new Notes and update the size of current ones. 2023-06-13 16:31:23 -04:00
Vitor Pamplona 1cd39e0d8f Removes the use of by on the quick action menu's state because it was triggering recompositions 2023-06-13 16:30:37 -04:00
Vitor Pamplona 305ecc6a2e New update removes access to the backQueue 2023-06-13 16:29:21 -04:00
Vitor Pamplona 858f94ae74 Moves icon sizes in the bottom row to remember functions 2023-06-13 16:28:42 -04:00
Vitor Pamplona fc224d99e3 Updates dependencies. 2023-06-13 10:52:29 -04:00
Vitor Pamplona b8584964d1 Activates build cache 2023-06-13 10:47:08 -04:00
Vitor Pamplona fbd0c96a8a Merge remote-tracking branch 'origin/HEAD' 2023-06-12 16:58:32 -04:00
Vitor Pamplona e2251f9c86 Removing the minimum size for rows in the feed due to Hidden posts from the user's block list. 2023-06-12 16:58:20 -04:00
Vitor Pamplona eaaacbf218 Turns NIP-05 verification symbol into a State variable to help correctly rendering updates. 2023-06-12 16:56:24 -04:00
Vitor Pamplona 243fbeed44 Remembers some variables on NoteCompose 2023-06-12 16:55:51 -04:00
Vitor Pamplona d62bab1b08 Makes relay badge list in chatroom Immutable 2023-06-12 16:55:27 -04:00
Vitor Pamplona 70e068ed17 Breaks down user renderer compositions 2023-06-12 16:55:08 -04:00
Vitor Pamplona 824e8795d5 Formatting 2023-06-12 16:54:45 -04:00
Vitor Pamplona f768a09bd8 Removes unused variable from Translation results 2023-06-12 16:54:15 -04:00
Vitor PamplonaandGitHub 1a69a5a4c7 Merge pull request #449 from greenart7c3/main
add option to opt-out from automatic spam and report filters
2023-06-12 16:37:03 -04:00
Vitor PamplonaandGitHub b9cc3781bb Merge pull request #451 from licaon-kter/patch-1
Typo fix
2023-06-12 11:10:22 -04:00
Licaon_KterandGitHub 97964ed65c Typo fix 2023-06-12 15:04:25 +00:00
Vitor Pamplona 42a1d157bf Fixes draft note in privacy.md 2023-06-12 10:25:03 -04:00
Vitor Pamplona e27efbc9c2 Privacy Policy:
- Removes restrictions of use from the F-Droid version given that F-Droid is taking that legal liability.
- Adds information about the Image Servers offered through the app.
- Adds privacy warnings from the readme to this document.
2023-06-12 10:23:29 -04:00
greenart7c3andGitHub 57a5d0b618 Merge branch 'main' into main 2023-06-12 07:39:30 -03:00
greenart7c3 8f668cb5fb separated spam and report filter options 2023-06-12 07:38:32 -03:00
Vitor Pamplona df5ea6f741 Adjusts the size and alignment of relay icons in the video feed 2023-06-11 10:59:18 -04:00
Vitor Pamplona 2e6847df1c v0.56.5 2023-06-10 21:20:57 -04:00
Vitor Pamplona 76a49d6556 Fixing upstream issue with images on markdown. 2023-06-10 21:18:30 -04:00
Vitor Pamplona 59fe3ccf3a Updates readme 2023-06-10 21:18:08 -04:00
Vitor Pamplona ba884bd1aa Improving markdown support 2023-06-10 21:17:50 -04:00
Vitor Pamplona e98bb76ee0 Support for NIP-14 2023-06-10 21:16:55 -04:00
Vitor Pamplona e08bf3eb97 If compression fails, tries to upload with the original file 2023-06-10 21:15:19 -04:00
Vitor Pamplona 9ef02d63a0 Refactors color objects to avoid recreating them on-demand. 2023-06-10 10:08:06 -04:00
Vitor Pamplona 7007796497 v0.56.4 2023-06-09 18:16:42 -04:00
Vitor Pamplona 9f030ffbf8 BugFix for crashing on start up due to creating two caches for videos. 2023-06-09 18:16:03 -04:00
Vitor Pamplona 9ca172e0fc v0.56.3 2023-06-09 17:12:29 -04:00
Vitor Pamplona 360758a2f3 Adds a networking state monitoring services to reconnect when the phone disconnects from relays. 2023-06-09 17:08:35 -04:00
Vitor Pamplona ffe6f4b55c Fixes F-droid version for mutable colors as background. 2023-06-09 16:43:47 -04:00
Vitor Pamplona 267b5e474c Adds another check to make sure the event does not exist in the local memory before notifying. 2023-06-09 16:41:14 -04:00
Vitor Pamplona bf6bc5ba68 Moves the recomposition due to background color change to each compose node. 2023-06-09 16:35:56 -04:00
Vitor Pamplona 1efc4c91e2 Reactors base note rendering to wrap the QuickAction menu 2023-06-09 14:07:38 -04:00
Vitor Pamplona a898196571 Refactoring shapes to the appropriate file. 2023-06-09 13:45:46 -04:00
Vitor Pamplona c158c7fc3d Moves the clipboard starters to their own compositions. 2023-06-09 13:19:14 -04:00
Vitor Pamplona e3d83fe39a improving the speed of the highlighter rendering 2023-06-09 11:27:27 -04:00
Vitor Pamplona 5327e1ddb2 Final touches on borders 2023-06-09 11:27:02 -04:00
Vitor Pamplona a2ad13c54a More spacing refinements for notes. 2023-06-09 10:49:10 -04:00
Vitor Pamplona 816a22ec69 More adjustments to borders and spacing between screen elements for different event types 2023-06-09 10:25:06 -04:00
Vitor Pamplona 8a99dc2db4 Removes unnecessary imports. 2023-06-09 10:03:11 -04:00
Vitor Pamplona f9cfe1479b - Breaks down the Note Composition stack further
- Fixes some border issues between multiple note types
- Aligns Quick Actions to the center of the note.
2023-06-09 10:02:56 -04:00
Vitor Pamplona 5d8aa7bb58 Refactoring variable name in Reactions 2023-06-09 10:00:37 -04:00
greenart7c3 f0638b1766 add option to opt-out from automatic spam and report filters 2023-06-09 08:28:05 -03:00
Vitor Pamplona b3e2b78415 v0.56.2 2023-06-08 18:16:53 -04:00
Vitor Pamplona d0094cc781 Removing duplicated ID protections from Notifications. Looks like we removed all the issues here. 2023-06-08 17:47:10 -04:00
Vitor Pamplona cb77298f52 Correctly processing multiple matching via find and not match 2023-06-08 17:46:47 -04:00
Vitor Pamplona 24532c8f20 Looks like there is never nothing to restore, so removing this. 2023-06-08 16:31:22 -04:00
Vitor Pamplona b641684377 Uses default thread to wait for a 250 milliseconds before navigating away from the video upload page. 2023-06-08 16:31:04 -04:00
Vitor Pamplona 9589f68028 Moves sending message on channel from the main thread to the IO thread 2023-06-08 16:30:29 -04:00
Vitor Pamplona 8b852d32e4 BugFix for notifications sometimes not reassembling when pressing the bottom button. 2023-06-08 16:28:48 -04:00
Vitor Pamplona 5b864fa1d9 Adds a second rule for short messages in the spam filter to avoid recognizing "@Bot, command" messages spam. 2023-06-08 15:52:35 -04:00
Vitor Pamplona 8e91987c31 Refactors search screen to make the SearchTextField @Stable 2023-06-08 15:51:46 -04:00
Vitor Pamplona 1c3b34a9c7 Moves the creation of the VideoPlayer to its own composition 2023-06-08 14:22:22 -04:00
Vitor PamplonaandGitHub 5a44f8d663 Merge pull request #448 from TonyGiorgio/onyx-hashtag
Add onyx hashtag
2023-06-08 12:30:36 -04:00
Vitor Pamplona 2ab7e6b24d v0.56.1 2023-06-08 12:18:06 -04:00
Vitor PamplonaandGitHub e9f8fd6cec Merge pull request #447 from TonyGiorgio/weed-hashtag
Add weed hashtag
2023-06-08 12:17:31 -04:00
Tony Giorgio 8d7060afe6 Add onyx hashtag 2023-06-08 11:15:47 -05:00
Vitor Pamplona 784f983746 Image and Video Compression 2023-06-08 12:14:26 -04:00
Tony Giorgio ecc4141f50 Add weed hashtag 2023-06-08 11:12:20 -05:00
Vitor PamplonaandGitHub b964c6a7fa Merge pull request #444 from ZsZolee/main
Adding one small Hungarian translation
2023-06-07 19:17:03 -04:00
Vitor PamplonaandGitHub 5d37e337bc Merge branch 'main' into main 2023-06-07 19:16:56 -04:00
Vitor Pamplona d9815b669b v0.56.0 2023-06-07 18:55:34 -04:00
Vitor Pamplona 6906993cc3 Support to per-post display reactions 2023-06-07 18:50:29 -04:00
Vitor Pamplona 705309cb8a v0.55.4 2023-06-07 17:17:46 -04:00
Vitor Pamplona 89bdc9dd58 Adds support for RelaySet event types. 2023-06-07 17:16:48 -04:00
Vitor Pamplona ac0103a53e v0.55.3 2023-06-07 15:57:31 -04:00
Vitor Pamplona b2c31d3b46 Avoid reusing SHA256 object because of a memory leak in multithreading 2023-06-07 15:49:15 -04:00
Vitor Pamplona 931bd58c51 Removes main thread checks on Verification due to the need to mock this method when running tests. 2023-06-07 13:59:12 -04:00
Vitor Pamplona 6a7d6a843d Minor updates to remember states in composition 2023-06-07 12:26:32 -04:00
Vitor Pamplona eaae672a30 Further breaks down the bottom bar to facilitate recompositions. 2023-06-07 12:25:49 -04:00
Vitor Pamplona ccfbfeffe2 Moves ChatroomList's New Feed to an Additive filter. 2023-06-07 12:25:11 -04:00
Vitor Pamplona eb62da8d26 Refactors the top bar relay updates. 2023-06-07 12:24:49 -04:00
Vitor Pamplona d334c6b2e1 BugFix for the Notification dot not immediately disappearing after marking all as read. 2023-06-07 12:24:24 -04:00
Vitor Pamplona 90f0e4aad1 Refactoring duplicated code in Clickable Route 2023-06-07 12:23:30 -04:00
Vitor Pamplona fabf52ffd7 Moves channel name to a remember state 2023-06-07 12:22:53 -04:00
Vitor Pamplona c499c4baec Adds more main thread checks to make sure slow procedures are run outside the main thread. 2023-06-07 12:08:13 -04:00
Vitor Pamplona 4bd19c3e3d Deletes hiddenUsers method from account to avoid misuse since it needs a blocking load from cache. 2023-06-07 12:07:03 -04:00
Vitor Pamplona 6a47f5aa3b Moves notification's scroll to top animation to an IO thread. 2023-06-07 12:06:18 -04:00
Vitor Pamplona 03d1754498 Moves collection events to an IO thread. 2023-06-07 12:05:45 -04:00
Vitor Pamplona e9374370b9 Moves author creation for Addressable notes to outside a @synchronized block to avoid deadlocks. 2023-06-07 12:04:50 -04:00
Vitor Pamplona 88a5e309bd Removes kinds from the filter since relays only allow 20 options. In this way, we get everything and filter it locally. 2023-06-07 12:03:44 -04:00
ZoltanandGitHub f7a292598e Adding one small Hungarian translation 2023-06-07 11:00:21 +02:00
Vitor Pamplona cff8684347 updating firebase version 2023-06-06 16:47:28 -04:00
Vitor Pamplona b9654d164f Further splitting the translation class into more methods to simplify recompositions 2023-06-06 16:47:14 -04:00
Vitor Pamplona c10db10430 Moving unstable parameters into stable ones to reduce recompositions. 2023-06-06 15:51:43 -04:00
Vitor Pamplona 50f3f7f176 Fixes a search bug that wouldn't update relay filters correctly with the search terms 2023-06-06 15:50:21 -04:00
Vitor Pamplona 49c3334509 activates compose reports 2023-06-06 15:49:52 -04:00
Vitor Pamplona e75f70985a upping memory requirements for the build 2023-06-06 15:49:29 -04:00
176 changed files with 6753 additions and 2843 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
run: ./gradlew assembleRelease --stacktrace
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
+41 -6
View File
@@ -1,13 +1,48 @@
# Amethyst Privacy Policy & Terms of Use
# Amethyst Privacy Policy and Terms of Use
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
## Privacy Policy
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Effective as of Jun 12, 2023
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts are only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
Amethyst offers a few options to upload pictures and videos in order to post them online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
### Privacy with Relays services
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
While the content of direct messages (DMs) is only visible to you, and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link on the DM text. Your uploaded pictures are available to anyone with that direct link.
### Visibility & Permanence of Your Content on Nostr Relays
#### Information Visibility
Content that you share can be shared with other relays by any user of the network.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to Nostr users who do not share relays with you.
#### 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.
## Terms of Use
### For versions downloaded from Google's Play Store
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
### For versions downloaded from F-Droid
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
## Other Notes
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
+4 -3
View File
@@ -61,14 +61,15 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
+16 -6
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 197
versionName "0.55.2"
versionCode 215
versionName "0.60.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -165,9 +165,14 @@ dependencies {
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:main-SNAPSHOT')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:main-SNAPSHOT')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:main-SNAPSHOT')
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -176,7 +181,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -188,6 +193,11 @@ dependencies {
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
@@ -1,18 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) = ExpandableRichTextViewer(
+18 -2
View File
@@ -4,17 +4,33 @@
<queries>
<package android:name="org.torproject.android"/>
</queries>
<!-- Doesn't require a camera -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- To Upload media (old devices) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- To read QRs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To read NFCs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
<!-- To know receive notifications when the app connects/disconnects from the web -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<application
@@ -49,6 +49,7 @@ private object PrefKeys {
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val REACTION_CHOICES = "reactionChoices"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
@@ -61,6 +62,9 @@ private object PrefKeys {
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -206,6 +210,7 @@ object LocalPreferences {
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
@@ -217,6 +222,9 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
@@ -254,6 +262,11 @@ object LocalPreferences {
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val reactionChoices = gson.fromJson<List<String>>(
getString(PrefKeys.REACTION_CHOICES, "[]"),
object : TypeToken<List<String>>() {}.type
).ifEmpty { listOf("+") } ?: listOf("+")
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
@@ -306,46 +319,52 @@ object LocalPreferences {
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute = try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, Long>>() {}.type
) as Map<String, Long>
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
mapOf()
}
val a = Account(
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList,
proxy,
proxyPort,
showSensitiveContent
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
hiddenUsers = hiddenUsers,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = zapAmountChoices,
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount() != null) return
@@ -1,53 +0,0 @@
package com.vitorpamplona.amethyst
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NotificationCache {
// TODO: This must be account-based
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData()
}
}
}
fun load(route: String): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
}
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(NotificationState(cache))
}
}
}
}
class NotificationState(val cache: NotificationCache)
@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst
object OptOutFromFilters {
var warnAboutPostsWithReports: Boolean = true
var filterSpamFromStrangers: Boolean = true
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnAboutReports
filterSpamFromStrangers = filterSpam
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import android.util.Log
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
@@ -31,11 +32,15 @@ object ServiceManager {
start(context)
}
@Synchronized
fun start(context: Context) {
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
Coil.setImageLoader {
ImageLoader.Builder(context).components {
if (Build.VERSION.SDK_INT >= 28) {
@@ -75,6 +80,8 @@ object ServiceManager {
}
fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
@@ -19,6 +19,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.model.*
@@ -55,6 +56,7 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var reactionChoices: List<String> = listOf("+"),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
@@ -66,17 +68,32 @@ class Account(
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int,
var showSensitiveContent: Boolean? = null
var showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>()
) {
var transientHiddenUsers: Set<String> = setOf()
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
val liveLastRead: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
if (!filterSpamFromStrangers) {
transientHiddenUsers = setOf()
}
live.invalidateData()
saveable.invalidateData()
}
fun userProfile(): User {
return userProfileCache ?: run {
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
@@ -89,10 +106,6 @@ class Account(
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -133,8 +146,8 @@ class Account(
}
}
fun reactionTo(note: Note): List<Note> {
return note.reactedBy(userProfile(), "+")
fun reactionTo(note: Note, reaction: String): List<Note> {
return note.reactedBy(userProfile(), reaction)
}
fun hasBoosted(note: Note): Boolean {
@@ -145,20 +158,20 @@ class Account(
return note.boostedBy(userProfile())
}
fun hasReacted(note: Note): Boolean {
return note.hasReacted(userProfile(), "+")
fun hasReacted(note: Note, reaction: String): Boolean {
return note.hasReacted(userProfile(), reaction)
}
fun reactTo(note: Note) {
fun reactTo(note: Note, reaction: String) {
if (!isWriteable()) return
if (hasReacted(note)) {
if (hasReacted(note, reaction)) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -318,9 +331,15 @@ class Account(
}
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
if (it.kind() == 1) {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
} else {
val event = GenericRepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
}
@@ -487,7 +506,11 @@ class Account(
mentions: List<User>?,
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>
) {
if (!isWriteable()) return
@@ -503,6 +526,10 @@ class Account(
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
replyingTo = replyingTo,
root = root,
directMentions = directMentions,
privateKey = loggedIn.privKey!!
)
@@ -520,7 +547,8 @@ class Account(
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null
) {
if (!isWriteable()) return
@@ -540,14 +568,15 @@ class Account(
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -561,13 +590,14 @@ class Account(
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) {
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -581,6 +611,7 @@ class Account(
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -847,6 +878,12 @@ class Account(
saveable.invalidateData()
}
fun changeReactionTypes(newTypes: List<String>) {
reactionChoices = newTypes
live.invalidateData()
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Nip47URI?) {
zapPaymentRequest = newServer
live.invalidateData()
@@ -1061,12 +1098,19 @@ class Account(
}
fun isAcceptable(user: User): Boolean {
if (!warnAboutPostsWithReports) {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() // if user has not reported this post
}
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
}
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
@@ -1079,7 +1123,7 @@ class Account(
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
isAcceptableDirect(note) &&
(
note.event !is RepostEvent ||
(note.event !is RepostEvent && note.event !is GenericRepostEvent) ||
(note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
@@ -1087,7 +1131,7 @@ class Account(
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports = if (note.event is RepostEvent) {
val innerReports = if (note.event is RepostEvent || note.event is GenericRepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
} else {
emptyList()
@@ -1124,6 +1168,19 @@ class Account(
live.invalidateData()
}
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadPerRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs)
saveable.invalidateData()
liveLastRead.invalidateData()
}
}
fun loadLastRead(route: String): Long {
return lastReadPerRoute[route] ?: 0
}
fun registerObservers() {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -20,11 +21,18 @@ class AntiSpamFilter {
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
if (!OptOutFromFilters.filterSpamFromStrangers) return false
val idHex = event.id
// if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false
// if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -11,11 +11,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
}
@@ -51,6 +51,18 @@ object LocalCache {
}
}
fun getUserIfExists(key: String): User? {
return users[key]
}
fun getNoteIfExists(key: String): Note? {
return addressables[key] ?: notes[key]
}
fun getChannelIfExists(key: String): Channel? {
return channels[key]
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
@@ -58,7 +70,14 @@ object LocalCache {
return checkGetOrCreateAddressableNote(key)
}
if (isValidHexNpub(key)) {
return getOrCreateNote(key)
val note = getOrCreateNote(key)
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
// upgrade to the latest
return checkGetOrCreateAddressableNote(noteEvent.address().toTag())
} else {
return note
}
}
return null
}
@@ -119,19 +138,27 @@ object LocalCache {
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
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)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toTag(), answer)
answer
}
}
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
}
return note
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
@@ -159,9 +186,15 @@ object LocalCache {
}
fun consume(event: PeopleListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -214,9 +247,15 @@ object LocalCache {
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
@@ -279,10 +318,54 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: PinListEvent) {
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
@@ -293,9 +376,15 @@ object LocalCache {
}
private fun consume(event: AudioTrackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -307,9 +396,15 @@ object LocalCache {
}
fun consume(event: BadgeDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -321,9 +416,15 @@ object LocalCache {
}
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -359,21 +460,35 @@ object LocalCache {
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -507,6 +622,31 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: GenericRepostEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
}
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id)
@@ -521,17 +661,8 @@ object LocalCache {
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
if (
event.content == "" ||
event.content == "+" ||
event.content == "\u2764\uFE0F" || // red heart
event.content == "\uD83E\uDD19" || // call me hand
event.content == "\uD83D\uDC4D" // thumbs up
) {
// Counts the replies
repliesTo.forEach {
it.addReaction(note)
}
repliesTo.forEach {
it.addReaction(note)
}
refreshObservers(note)
@@ -1007,10 +1138,17 @@ object LocalCache {
live.invalidateData(newNote)
}
fun consume(event: Event, relay: Relay?) {
fun verifyAndConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
if (!event.hasValidSignature()) return
if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
}
try {
when (event) {
@@ -1033,9 +1171,10 @@ object LocalCache {
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -1049,10 +1188,17 @@ object LocalCache {
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
is GenericRepostEvent -> {
event.containedPost()?.let {
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -4,11 +4,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -42,7 +44,7 @@ open class Note(val idHex: String) {
// These fields are updated every time an event related to this note is received.
var replies = setOf<Note>()
private set
var reactions = setOf<Note>()
var reactions = mapOf<String, Set<Note>>()
private set
var boosts = setOf<Note>()
private set
@@ -95,7 +97,7 @@ open class Note(val idHex: String) {
*/
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
@@ -108,7 +110,7 @@ open class Note(val idHex: String) {
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return 0
}
@@ -133,8 +135,20 @@ open class Note(val idHex: String) {
liveSet?.boosts?.invalidateData()
}
fun removeReaction(note: Note) {
reactions = reactions - note
liveSet?.reactions?.invalidateData()
val reaction = note.event?.content() ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
}
}
}
fun removeReport(deleteNote: Note) {
@@ -179,6 +193,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
@@ -190,6 +205,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
@@ -200,8 +216,13 @@ open class Note(val idHex: String) {
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
val reaction = note.event?.content() ?: "+"
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note))
liveSet?.reactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
liveSet?.reactions?.invalidateData()
}
}
@@ -240,8 +261,10 @@ open class Note(val idHex: String) {
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
fun isReactedBy(user: User): String? {
return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
}.keys.firstOrNull()
}
fun isBoostedBy(user: User): Boolean {
@@ -266,6 +289,10 @@ open class Note(val idHex: String) {
}.flatten()
}
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
@@ -346,7 +373,7 @@ open class Note(val idHex: String) {
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
return event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasZapped(loggedIn: User): Boolean {
@@ -358,7 +385,11 @@ open class Note(val idHex: String) {
}
fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions.filter { it.author == loggedIn && it.event?.content() == content }
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
}
fun reactedBy(loggedIn: User): List<String> {
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
@@ -370,6 +401,42 @@ open class Note(val idHex: String) {
return boosts.filter { it.author == loggedIn }
}
fun moveAllReferencesTo(note: AddressableNote) {
// migrates these comments to a new version
replies.forEach {
note.addReply(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reactions.forEach {
it.value.forEach {
note.addReaction(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
boosts.forEach {
note.addBoost(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reports.forEach {
it.value.forEach {
note.addReport(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
zaps.forEach {
note.addZap(it.key, it.value)
it.key.replyTo = it.key.replyTo?.updated(this, note)
it.value?.replyTo = it.value?.replyTo?.updated(this, note)
}
replyTo = null
replies = emptySet()
reactions = emptyMap()
boosts = emptySet()
reports = emptyMap()
zaps = emptyMap()
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.relays.FeedType
@Immutable
data class RelaySetupInfo(
val url: String,
val read: Boolean,
@@ -1,6 +1,9 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -9,6 +12,8 @@ class ThreadAssembler {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
@@ -35,6 +40,8 @@ class ThreadAssembler {
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
@@ -1,26 +1,21 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
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
object UrlCachedPreviewer {
var cache = mapOf<String, UrlInfoItem>()
private set
var failures = mapOf<String, Throwable>()
var cache = LruCache<String, UrlPreviewState>(100)
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
checkNotInMainThread()
cache[url]?.let {
callback?.onComplete(it)
return
}
failures[url]?.let {
callback?.onFailed(it)
onReady(it)
return
}
@@ -28,13 +23,32 @@ object UrlCachedPreviewer {
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
}
}
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
}
).fetchUrlPreview()
@@ -96,10 +96,10 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
}
liveSet?.relays?.invalidateData()
@@ -340,6 +340,7 @@ class User(val pubkeyHex: String) {
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val followers: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
@@ -351,6 +352,7 @@ class UserLiveSet(u: User) {
fun isInUse(): Boolean {
return follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
@@ -362,6 +364,7 @@ class UserLiveSet(u: User) {
}
}
@Immutable
data class RelayInfo(
val url: String,
var lastEvent: Long,
@@ -403,7 +406,7 @@ class UserMetadata {
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
.any { it.contains(prefix, true) }
}
fun lnAddress(): String? {
@@ -13,8 +13,6 @@ import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -33,6 +31,8 @@ class Nip05Verifier() {
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
@@ -47,7 +47,7 @@ class Nip05Verifier() {
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
@@ -70,6 +70,9 @@ class Nip05Verifier() {
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
// checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
@@ -88,6 +88,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.consume(event, relay)
LocalCache.verifyAndConsume(event, relay)
}
}
@@ -3,10 +3,12 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -58,7 +60,16 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(
TextNoteEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
LongTextNoteEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -48,14 +48,14 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 20
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
search = mySearchString,
limit = 20
limit = 100
)
)
)
@@ -26,7 +26,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
@@ -77,6 +77,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
@@ -114,17 +115,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
ids = interestedEvents.toList()
)
)
@@ -28,7 +28,16 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(
TextNoteEvent.kind,
GenericRepostEvent.kind,
RepostEvent.kind,
LongTextNoteEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -45,6 +46,8 @@ class LightningAddressResolver() {
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -98,7 +99,7 @@ object LnInvoiceUtil {
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
private fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
@@ -121,6 +122,8 @@ object LnInvoiceUtil {
}
fun getAmountInSats(invoice: String): BigDecimal {
checkNotInMainThread()
return getAmount(invoice).multiply(BigDecimal(100000000))
}
@@ -35,7 +35,8 @@ class ChannelMessageEvent(
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
@@ -54,6 +55,9 @@ class ChannelMessageEvent(
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)
@@ -54,6 +54,10 @@ open class Event(
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0].equals("zapraiser", true))
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
@@ -138,7 +142,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
@@ -212,7 +216,6 @@ open class Event(
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
@@ -246,7 +249,9 @@ open class Event(
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
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)
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)
@@ -259,6 +264,7 @@ open class Event(
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -283,7 +289,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
@@ -38,4 +38,5 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
}
@@ -0,0 +1,52 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class GenericRepostEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
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 containedPost() = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
companion object {
const val kind = 16
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): GenericRepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}
tags = tags + listOf(listOf("k", "${boostedPost.kind()}"))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,48 @@
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 LiveActivitiesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
companion object {
const val kind = 30311
fun create(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LiveActivitiesEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return LiveActivitiesEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.nio.charset.Charset
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
@@ -133,7 +134,7 @@ class LnZapRequestEvent(
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return sha256.digest(strbyte)
return MessageDigest.getInstance("SHA-256").digest(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
@@ -52,7 +52,8 @@ class PollNoteEvent(
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -79,6 +80,9 @@ class PollNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -78,7 +78,8 @@ class PrivateDmEvent(
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -102,6 +103,9 @@ class PrivateDmEvent(
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 PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -0,0 +1,44 @@
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 RelaySetEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
fun description() = tags.firstOrNull() { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30022
fun create(
relays: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RelaySetEvent {
val tags = mutableListOf<List<String>>()
relays.forEach {
tags.add(listOf("r", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return RelaySetEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -36,7 +36,7 @@ class RepostEvent(
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
@@ -19,6 +19,10 @@ class TextNoteEvent(
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1)
companion object {
const val kind = 1
@@ -30,19 +34,45 @@ class TextNoteEvent(
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
if (it == replyingTo) {
tags.add(listOf("e", it, "", "reply"))
} else if (it == root) {
tags.add(listOf("e", it, "", "root"))
} else if (it in directMentions) {
tags.add(listOf("e", it, "", "mention"))
} else {
tags.add(listOf("e", it))
}
}
mentions?.forEach {
tags.add(listOf("p", it))
if (it in directMentions) {
tags.add(listOf("p", it, "", "mention"))
} else {
tags.add(listOf("p", it))
}
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
val aTag = it.toTag()
if (aTag == replyingTo) {
tags.add(listOf("a", aTag, "", "reply"))
} else if (aTag == root) {
tags.add(listOf("a", aTag, "", "root"))
} else if (aTag in directMentions) {
tags.add(listOf("a", aTag, "", "mention"))
} else {
tags.add(listOf("a", aTag))
}
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
@@ -60,6 +90,9 @@ class TextNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -22,12 +22,14 @@ class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
// adds to database
LocalCache.consume(event, null)
if (LocalCache.notes[event.id] == null) {
// adds to database
LocalCache.verifyAndConsume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
@@ -18,6 +19,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
@@ -72,6 +72,8 @@ class Relay(
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
checkNotInMainThread()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
@@ -92,6 +94,8 @@ class Relay(
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
afterEOSE = false
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
@@ -102,6 +106,8 @@ class Relay(
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
@@ -156,6 +162,8 @@ class Relay(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
@@ -166,6 +174,8 @@ class Relay(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
socket = null
isReady = false
afterEOSE = false
@@ -174,6 +184,8 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
errorCounter++
socket?.close(1000, "Normal close")
@@ -212,6 +224,8 @@ class Relay(
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
@@ -236,6 +250,8 @@ class Relay(
}
fun sendFilterOnlyIfDisconnected() {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
@@ -246,6 +262,8 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
@@ -48,6 +49,8 @@ object RelayPool : Relay.Listener {
}
fun requestAndWatch() {
checkNotInMainThread()
relays.forEach { it.requestAndWatch() }
}
@@ -55,6 +55,9 @@ class TypedFilter(
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
filter.search?.run {
jsonObject.addProperty("search", filter.search)
}
return jsonObject
}
}
@@ -0,0 +1,162 @@
/*
* Created by Ayaan on 02/02/23, 10:30 pm
* Copyright (c) 2023 . All rights reserved.
* Last modified 02/02/23, 9:56 pm
*/
package com.vitorpamplona.amethyst.service.tts
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import java.util.Locale
const val DEF_SPEECH_AND_PITCH = 0.8f
fun getErrorText(errorCode: Int): String = when (errorCode) {
TextToSpeech.ERROR -> "ERROR"
TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST"
TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK"
TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT"
TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE"
TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS"
TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET"
else -> "UNKNOWN"
}
class TextToSpeechEngine private constructor() {
private var tts: TextToSpeech? = null
private var defaultPitch = 0.8f
private var defaultSpeed = 0.8f
private var defLanguage = Locale.getDefault()
private var onStartListener: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null
private var onHighlightListener: ((Int, Int) -> Unit)? = null
private var message: String? = null
companion object {
private var instance: TextToSpeechEngine? = null
fun getInstance(): TextToSpeechEngine {
if (instance == null) {
instance = TextToSpeechEngine()
}
return instance!!
}
}
fun initTTS(context: Context, message: String) {
tts = TextToSpeech(context) {
if (it == TextToSpeech.SUCCESS) {
tts!!.language = defLanguage
tts!!.setPitch(defaultPitch)
tts!!.setSpeechRate(defaultSpeed)
tts!!.setListener(
onStart = {
onStartListener?.invoke()
},
onError = { e ->
e?.let { error ->
onErrorListener?.invoke(error)
}
},
onRange = { start, end ->
if (this@TextToSpeechEngine.message != null) {
onHighlightListener?.invoke(start, end)
}
},
onDone = {
onStartListener?.invoke()
}
)
speak(message)
} else {
onErrorListener?.invoke(getErrorText(it))
}
}
}
private fun speak(message: String): TextToSpeechEngine {
tts!!.speak(
message,
TextToSpeech.QUEUE_FLUSH,
null,
TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED
)
return this
}
fun setPitchAndSpeed(pitch: Float, speed: Float) {
defaultPitch = pitch
defaultSpeed = speed
}
fun resetPitchAndSpeed() {
defaultPitch = DEF_SPEECH_AND_PITCH
defaultSpeed = DEF_SPEECH_AND_PITCH
}
fun setLanguage(local: Locale): TextToSpeechEngine {
this.defLanguage = local
return this
}
fun setHighlightedMessage(message: String) {
this.message = message
}
fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine {
this.onStartListener = onStartListener
return this
}
fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine {
this.onDoneListener = onDoneListener
return this
}
fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine {
this.onErrorListener = onErrorListener
return this
}
fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine {
this.onHighlightListener = onHighlightListener
return this
}
fun destroy() {
tts?.stop()
tts?.shutdown()
tts = null
instance = null
}
}
inline fun TextToSpeech.setListener(
crossinline onStart: (String?) -> Unit = {},
crossinline onError: (String?) -> Unit = {},
crossinline onRange: (Int, Int) -> Unit = { _, _ -> },
crossinline onDone: (String?) -> Unit
) = this.apply {
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(p0: String?) {
onStart.invoke(p0)
}
override fun onDone(p0: String?) {
onDone.invoke(p0)
}
@Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)"))
override fun onError(p0: String?) {
onError.invoke(p0)
}
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
super.onRangeStart(utteranceId, start, end, frame)
onRange.invoke(start, end)
}
})
}
@@ -0,0 +1,158 @@
/*
* Created by Ayaan on 02/02/23, 10:30 pm
* Copyright (c) 2023 . All rights reserved.
* Last modified 02/02/23, 10:19 pm
*/
package com.vitorpamplona.amethyst.service.tts
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import java.lang.ref.WeakReference
import java.util.Locale
class TextToSpeechHelper private constructor(private val context: WeakReference<Context>) :
LifecycleEventObserver {
private val appContext
get() = context.get()!!.applicationContext
private var message: String? = null
private var tts: TextToSpeechEngine? = null
private var onStart: (() -> Unit)? = null
private var onDoneListener: (() -> Unit)? = null
private var onErrorListener: ((String) -> Unit)? = null
private var onHighlightListener: ((Pair<Int, Int>) -> Unit)? = null
private var customActionForDestroy: (() -> Unit)? = null
init {
initTTS()
}
fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper {
owner.lifecycle.addObserver(this)
return this
}
private fun initTTS() = context.get()?.run {
tts = TextToSpeechEngine.getInstance()
.setOnCompletionListener { onDoneListener?.invoke() }
.setOnErrorListener { onErrorListener?.invoke(it) }
.setOnStartListener { onStart?.invoke() }
}
fun speak(message: String): TextToSpeechHelper {
if (tts == null) {
initTTS()
}
this.message = message
tts?.initTTS(
appContext,
message
)
return this
}
/**
* This method will highlight the text in the textView
*
* @exception Exception("Message can't be null for highlighting !! Call speak() first")
*/
fun highlight(): TextToSpeechHelper {
if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first")
tts?.setHighlightedMessage(message!!)
tts?.setOnHighlightListener { i, i2 ->
onHighlightListener?.invoke(Pair(i, i2))
}
return this
}
fun removeHighlight(): TextToSpeechHelper {
message = null
onHighlightListener = null
return this
}
fun destroy(
action: (() -> Unit) = {}
) {
tts?.destroy()
tts = null
action.invoke()
INSTANCE = null
}
fun onStart(onStartListener: () -> Unit): TextToSpeechHelper {
this.onStart = onStartListener
return this
}
fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper {
this.onDoneListener = onCompleteListener
return this
}
fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper {
this.onErrorListener = onErrorListener
return this
}
fun onHighlight(onHighlightListener: (Pair<Int, Int>) -> Unit): TextToSpeechHelper {
this.onHighlightListener = onHighlightListener
return this
}
fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper {
customActionForDestroy = action
return this
}
fun setLanguage(locale: Locale): TextToSpeechHelper {
tts?.setLanguage(locale)
return this
}
fun setPitchAndSpeed(
pitch: Float = DEF_SPEECH_AND_PITCH,
speed: Float = DEF_SPEECH_AND_PITCH
): TextToSpeechHelper {
tts?.setPitchAndSpeed(pitch, speed)
return this
}
fun resetPitchAndSpeed(): TextToSpeechHelper {
tts?.resetPitchAndSpeed()
return this
}
companion object {
private var INSTANCE: TextToSpeechHelper? = null
fun getInstance(context: Context): TextToSpeechHelper {
synchronized(TextToSpeechHelper::class.java) {
if (INSTANCE == null) {
INSTANCE = TextToSpeechHelper(WeakReference(context))
}
return INSTANCE!!
}
}
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY ||
event == Lifecycle.Event.ON_STOP ||
event == Lifecycle.Event.ON_PAUSE
) {
destroy {
customActionForDestroy?.invoke()
}
}
}
}
@@ -2,7 +2,12 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
@@ -56,6 +61,15 @@ class MainActivity : FragmentActivity() {
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
Client.lenient = true
}
@@ -90,6 +104,37 @@ class MainActivity : FragmentActivity() {
ServiceManager.cleanUp()
}
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
ServiceManager.start(this@MainActivity)
}
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
}
// lost network connection
override fun onLost(network: Network) {
super.onLost(network)
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
}
}
}
}
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
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.service.HttpClient
@@ -22,19 +23,24 @@ object ImageUploader {
fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
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
checkNotNull(imageInputStream) {
"Can't open the image input stream"
@@ -57,7 +63,7 @@ object ImageUploader {
}
}
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
}
fun uploadImage(
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
@@ -28,6 +29,8 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.remember
@@ -55,11 +58,13 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
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.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -73,13 +78,27 @@ import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.account
val searchBarViewModel: SearchBarViewModel = viewModel(
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
factory = SearchBarViewModel.Factory(
accountViewModel.account
)
)
JoinUserOrChannelView(
searchBarViewModel = searchBarViewModel,
onClose = onClose,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clean()
searchBarViewModel.clear()
onClose()
},
properties = DialogProperties(
@@ -88,7 +107,9 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
modifier = Modifier
.padding(10.dp)
.heightIn(min = 500.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -96,7 +117,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
searchBarViewModel.clean()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
})
@@ -108,35 +129,27 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
Text(
text = "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
color = MaterialTheme.colors.placeholderText,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, accountViewModel, nav)
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RenderSeach(
private fun RenderSearch(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onlineSearch = NostrSearchEventOrUserDataSource
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
@@ -147,7 +160,8 @@ private fun RenderSeach(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData()
}
}
@@ -155,9 +169,6 @@ private fun RenderSeach(
}
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges.receiveAsFlow()
@@ -166,13 +177,13 @@ private fun RenderSeach(
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) {
launch(Dispatchers.Main) {
listState.animateScrollToItem(0)
}
}
@@ -200,8 +211,34 @@ private fun RenderSeach(
}
// LAST ROW
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SearchEditTextForJoin(
searchBarViewModel: SearchBarViewModel,
searchTextChanges: Channel<String>
) {
val scope = rememberCoroutineScope()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(100)
focusRequester.requestFocus()
}
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
modifier = Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -234,15 +271,15 @@ private fun RenderSeach(
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
}
) {
Icon(
@@ -254,10 +291,24 @@ private fun RenderSeach(
}
)
}
}
@Composable
private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
if (searchBarViewModel.searchValue.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -268,50 +319,60 @@ private fun RenderSeach(
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResultsUsers.value,
users,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
accountViewModel = accountViewModel,
nav = nav
)
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
searchBarViewModel.clear()
}
}
itemsIndexed(
searchBarViewModel.searchResultsChannels.value,
channels,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { nav("Channel/${item.idHex}") }
)
RenderChannel(item) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
}
}
}
}
}
@Composable
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
onClick: () -> Unit
) {
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = onClick
)
}
@Composable
fun UserComposeForChat(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
onClick: () -> Unit
) {
Column(
modifier =
Modifier.clickable(
onClick = { nav("Room/${baseUser.pubkeyHex}") }
onClick = onClick
)
) {
Row(
@@ -323,22 +384,18 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, accountViewModel, 55.dp)
UserPicture(baseUser, 55.dp, accountViewModel)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
val baseUserState by baseUser.live().metadata.observeAsState()
val user = baseUserState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
DisplayUserAboutInfo(baseUser)
}
}
@@ -348,3 +405,20 @@ fun UserComposeForChat(
)
}
}
@Composable
private fun DisplayUserAboutInfo(baseUser: User) {
val baseUserState by baseUser.live().metadata.observeAsState()
val about by remember(baseUserState) {
derivedStateOf {
baseUserState?.user?.info?.about ?: ""
}
}
Text(
text = about,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -27,6 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -79,7 +80,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = stringResource(R.string.my_awesome_group),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -98,7 +99,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -115,7 +116,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
placeholder = {
Text(
text = stringResource(R.string.about_us),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -10,10 +11,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -54,33 +57,56 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = true
val contentResolver = context.contentResolver
val uri = galleryUri ?: return
val myGalleryUri = galleryUri ?: return
val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) {
val contentType = contentResolver.getType(myGalleryUri)
viewModelScope.launch(Dispatchers.IO) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
uploadingDescription.value = "Compress"
MediaCompressor().compress(
myGalleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
} else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
@@ -162,7 +188,7 @@ open class NewMediaModel : ViewModel() {
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
@@ -173,7 +199,7 @@ open class NewMediaModel : ViewModel() {
description,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
uploadingPercentage.value = 0.40f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
@@ -33,8 +33,11 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -47,8 +50,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -124,8 +132,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -172,7 +180,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
VideoView(it.toString())
}
}
}
@@ -214,7 +222,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -8,11 +9,16 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
val directMentions = mutableSetOf<HexKey>()
fun addUserToMentions(user: User) {
directMentions.add(user.pubkeyHex)
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
directMentions.add(note.idHex)
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollClosing(pollViewModel: NewPostViewModel) {
@@ -43,7 +44,7 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
unfocusedBorderColor = MaterialTheme.colors.placeholderText
)
Row(
@@ -59,13 +60,13 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_closing_time),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_closing_time_days),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
@@ -43,7 +44,7 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
unfocusedBorderColor = MaterialTheme.colors.placeholderText
)
Row(
@@ -59,13 +60,13 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_consensus_threshold),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -11,6 +11,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
@@ -35,13 +36,13 @@ fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_option_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -38,7 +39,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
unfocusedBorderColor = MaterialTheme.colors.placeholderText
)
OutlinedTextField(
@@ -49,7 +50,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -67,7 +68,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
placeholder = {
Text(
text = stringResource(R.string.poll_primary_description),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
colors = if (isInputValid) colorValid else colorInValid,
@@ -10,6 +10,7 @@ import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
@@ -29,13 +30,13 @@ fun NewPollRecipientsField(pollViewModel: NewPostViewModel, account: Account) {
label = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_zap_recipients),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
@@ -71,7 +72,7 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
unfocusedBorderColor = MaterialTheme.colors.placeholderText
)
Row(
@@ -87,13 +88,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_zap_value_min),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -106,13 +107,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
label = {
Text(
text = stringResource(R.string.poll_zap_value_max),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.outlined.ArrowForwardIos
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -70,10 +72,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -97,8 +106,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -163,7 +176,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scrollState)
) {
Notifying(postViewModel.mentions) {
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
@@ -191,7 +204,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
colors = TextFieldDefaults
@@ -210,9 +223,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Button(
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
border = BorderStroke(1.dp, MaterialTheme.colors.placeholderText),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
contentColor = MaterialTheme.colors.placeholderText
)
) {
Image(
@@ -267,6 +280,15 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
@@ -281,11 +303,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
@@ -294,10 +316,15 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
BechLink(
myUrlPreview,
true,
MaterialTheme.colors.background,
backgroundColor,
accountViewModel,
nav
)
@@ -357,6 +384,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
@@ -373,7 +406,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
@@ -381,7 +414,7 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
Text(
stringResource(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
mentions.forEachIndexed { idx, user ->
@@ -390,13 +423,13 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
Spacer(modifier = Modifier.width(5.dp))
val tags = remember(innerUserState) {
myUser.info?.latestMetadata?.tags?.toImmutableList()
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
Button(
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
backgroundColor = MaterialTheme.colors.mediumImportanceLink
),
onClick = {
onClick(myUser)
@@ -443,6 +476,56 @@ private fun AddPollButton(
}
}
@Composable
private fun AddZapraiserButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.BottomEnd),
tint = BitcoinOrange
)
}
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
@@ -537,7 +620,7 @@ private fun ForwardZapTo(
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
color = MaterialTheme.colors.placeholderText,
fontSize = 14.sp
)
},
@@ -612,7 +695,7 @@ fun CloseButton(onCancel: () -> Unit) {
onClick = {
onCancel()
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -636,7 +719,7 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -655,7 +738,7 @@ fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -674,7 +757,7 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -693,7 +776,7 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -747,8 +830,8 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
@@ -757,11 +840,11 @@ fun ImageVideoDescription(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column(
@@ -805,7 +888,7 @@ fun ImageVideoDescription(
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
tint = MaterialTheme.colors.placeholderText
)
}
}
@@ -854,7 +937,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri)
VideoView(uri.toString())
}
}
@@ -895,7 +978,7 @@ fun ImageVideoDescription(
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -912,7 +995,7 @@ fun ImageVideoDescription(
onClick = {
onAdd(message, selectedServer)
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -922,3 +1005,10 @@ fun ImageVideoDescription(
}
}
}
@Stable
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
@@ -18,12 +19,14 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var originalNote: Note? = null
@@ -71,6 +74,11 @@ open class NewPostViewModel() : ViewModel() {
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -102,11 +110,14 @@ open class NewPostViewModel() : ViewModel() {
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -127,48 +138,85 @@ open class NewPostViewModel() : ViewModel() {
null
}
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
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)
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)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
val replyId = originalNote?.idHex
account?.sendPost(
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions
)
}
cancel()
}
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) {
isUploadingImage = true
contentToAddUrl = null
val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
if (server == ServersAvailable.NIP95) {
val contentType = contentResolver.getType(it)
contentResolver.openInputStream(it)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
} else {
ImageUploader.uploadImage(
uri = it,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
viewModelScope.launch(Dispatchers.IO) {
MediaCompressor().compress(
galleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
imageUploadingError.emit(it)
}
}
)
@@ -191,6 +239,8 @@ open class NewPostViewModel() : ViewModel() {
closedAt = null
wantsInvoice = false
wantsZapraiser = false
zapRaiserAmount = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
@@ -296,8 +346,7 @@ open class NewPostViewModel() : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -52,6 +51,9 @@ import com.vitorpamplona.amethyst.R
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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import java.lang.Math.round
@Composable
@@ -157,7 +159,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -167,7 +169,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -177,7 +179,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Spacer(modifier = Modifier.size(5.dp))
@@ -187,7 +189,7 @@ fun ServerConfigHeader() {
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Spacer(modifier = Modifier.size(2.dp))
@@ -375,7 +377,7 @@ fun ServerConfig(
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
IconButton(
@@ -403,7 +405,7 @@ fun ServerConfig(
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Icon(
@@ -420,7 +422,7 @@ fun ServerConfig(
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
Icon(
@@ -435,7 +437,7 @@ fun ServerConfig(
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
}
@@ -464,7 +466,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
color = MaterialTheme.colors.placeholderText,
maxLines = 1
)
},
@@ -476,9 +478,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
imageVector = Icons.Default.Download,
null,
modifier = Modifier
.size(35.dp)
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
tint = if (read) Color.Green else MaterialTheme.colors.placeholderText
)
}
@@ -487,9 +489,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
imageVector = Icons.Default.Upload,
null,
modifier = Modifier
.size(35.dp)
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
tint = if (write) Color.Green else MaterialTheme.colors.placeholderText
)
}
@@ -504,10 +506,10 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
read = true
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.placeholderText
)
) {
Text(text = stringResource(id = R.string.add), color = Color.White)
@@ -1,15 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NewRelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -24,7 +28,9 @@ class NewRelayListViewModel : ViewModel() {
fun create() {
relays.let {
account.saveRelayList(it.value)
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
}
}
clear()
@@ -46,7 +52,7 @@ class NewRelayListViewModel : ViewModel() {
if (relayFile != null) {
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
@@ -28,6 +28,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
@@ -37,8 +41,12 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -88,7 +96,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.my_display_name),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -107,7 +115,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.my_username),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true
@@ -126,7 +134,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(id = R.string.about_me),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -145,13 +153,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
tint = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForPicture(it, context)
@@ -170,13 +178,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForBanner,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
tint = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForBanner(it, context)
@@ -195,7 +203,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true
@@ -211,7 +219,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true
@@ -226,7 +234,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true
@@ -242,7 +250,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -257,7 +265,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.twitter_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -272,7 +280,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.mastodon_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -287,7 +295,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
placeholder = {
Text(
text = stringResource(R.string.github_proof_url_template),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
}
)
@@ -13,6 +13,9 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import id.zelory.compressor.Compressor.compress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
@@ -119,7 +122,9 @@ class NewUserMetadataViewModel : ViewModel() {
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
}
clear()
}
@@ -140,46 +145,67 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun uploadForPicture(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForPicture = it
},
onUploaded = {
picture.value = it
}
)
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = {
isUploadingImageForPicture = it
},
onUploaded = {
picture.value = it
}
)
}
}
fun uploadForBanner(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForBanner = it
},
onUploaded = {
banner.value = it
}
)
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = {
isUploadingImageForBanner = it
},
onUploaded = {
banner.value = it
}
)
}
}
fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
private suspend fun upload(galleryUri: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
onUploading(true)
ImageUploader.uploadImage(
uri = it,
server = account.defaultFileServer,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
val contentResolver = context.contentResolver
MediaCompressor().compress(
galleryUri,
contentResolver.getType(galleryUri),
context.applicationContext,
onReady = { fileUri, contentType, size ->
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
imageUploadingError.emit(it)
}
}
)
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
@@ -12,11 +11,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.coroutines.launch
import java.io.File
@@ -75,7 +74,7 @@ fun SaveToGallery(url: String) {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -135,7 +134,7 @@ fun SaveToGallery(localFile: File, mimeType: String?) {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -28,6 +28,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@@ -99,21 +101,23 @@ private fun UploadBoxButton(
}
}
val DefaultAnimationColors = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: List<Color> = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
),
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000
) {
val infiniteTransition = rememberInfiniteTransition()
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -14,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
@Stable
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
@@ -50,6 +53,7 @@ class BundledUpdate(
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
@Stable
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
@@ -58,6 +62,8 @@ class BundledInsert<T>(
private var queue = LinkedBlockingQueue<T>()
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
return
@@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -60,20 +62,46 @@ fun ClickableRoute(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
if (nip19.type == Nip19.Type.USER) {
DisplayUser(nip19, nav)
} else if (nip19.type == Nip19.Type.ADDRESS) {
DisplayAddress(nip19, nav)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, nav)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, nav)
} else {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, nav)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, nav)
}
else -> {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
}
@Composable
private fun LoadNote(
hex: String,
content: @Composable (Note) -> Unit
) {
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
LaunchedEffect(key1 = hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
)
}
}
noteBase?.let {
content(it)
}
}
@@ -82,69 +110,8 @@ private fun DisplayEvent(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
}
if (noteBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
@@ -153,66 +120,61 @@ private fun DisplayNote(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
@Composable
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
nav: (String) -> Unit
) {
val noteState by it.live().metadata.observeAsState()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Note/${nip19.hex}" },
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
}
if (noteBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
}
@@ -222,11 +184,13 @@ private fun DisplayAddress(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
}
}
@@ -259,32 +223,18 @@ private fun DisplayUser(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
if (userBase == null) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
}
}
userBase?.let {
val userState by it.live().metadata.observeAsState()
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val addedCharts = remember {
"${nip19.additionalChars} "
}
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = addedCharts,
tags = userTags,
route = route,
nav = nav
)
}
RenderUserAsClickableText(it, nip19, nav)
}
if (userBase == null) {
@@ -296,6 +246,42 @@ private fun DisplayUser(
}
}
@Composable
private fun RenderUserAsClickableText(
baseUser: User,
nip19: Nip19.Return,
nav: (String) -> Unit
) {
val userState by baseUser.live().metadata.observeAsState()
val route = remember { "User/${baseUser.pubkeyHex}" }
val userDisplayName by remember(userState) {
derivedStateOf {
userState?.user?.toBestDisplayName()
}
}
val userTags by remember(userState) {
derivedStateOf {
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
val addedCharts = remember(nip19) {
"${nip19.additionalChars} "
}
userDisplayName?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = addedCharts,
tags = userTags,
route = route,
nav = nav
)
}
}
@Composable
fun CreateClickableText(
clickablePart: String,
@@ -325,7 +311,7 @@ fun CreateClickableText(
@Composable
fun CreateTextWithEmoji(
text: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
@@ -339,7 +325,7 @@ fun CreateTextWithEmoji(
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(text, emojis)
@@ -440,7 +426,7 @@ fun CreateTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
@@ -449,7 +435,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
@@ -477,7 +463,7 @@ fun CreateClickableTextWithEmoji(
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
@@ -490,7 +476,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
@@ -1,18 +1,17 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -20,14 +19,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
const val SHORT_TEXT_LENGTH = 350
@@ -36,8 +37,8 @@ fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -79,14 +80,16 @@ fun ExpandableRichTextViewer(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
)
}
) {
ShowMoreButton() {
showFullText = !showFullText
@@ -97,14 +100,13 @@ fun ExpandableRichTextViewer(
}
@Composable
private fun ShowMoreButton(onClick: () -> Unit) {
fun ShowMoreButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
backgroundColor = MaterialTheme.colors.secondaryButtonBackground
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
@@ -8,7 +8,6 @@ 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.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -25,21 +24,22 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
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 java.math.BigDecimal
import java.text.NumberFormat
@Composable
fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
e.printStackTrace()
null
@@ -60,15 +60,15 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
@@ -100,9 +100,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
amount?.let {
Text(
text = "${
NumberFormat.getInstance().format(amount)
} ${stringResource(id = R.string.sats)}",
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
@@ -121,7 +119,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
startActivity(context, intent, null)
}
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -7,7 +7,6 @@ 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
@@ -37,6 +36,9 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.launch
@Composable
@@ -56,8 +58,8 @@ fun InvoiceRequest(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
@@ -98,7 +100,7 @@ fun InvoiceRequest(
placeholder = {
Text(
text = stringResource(R.string.thank_you_so_much),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -123,7 +125,7 @@ fun InvoiceRequest(
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
@@ -153,7 +155,7 @@ fun InvoiceRequest(
}
)
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -0,0 +1,111 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
class MediaCompressor {
suspend fun compress(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (String) -> Unit
) {
if (contentType?.startsWith("video", true) == true) {
VideoCompressor.start(
context = applicationContext, // => This is required
uris = listOf(uri), // => Source can be provided as content uris
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(
videoName = UUID.randomUUID().toString() // => required name
),
configureWith = Configuration(
quality = VideoQuality.LOW
),
listener = object : CompressionListener {
override fun onProgress(index: Int, percent: Float) {
}
override fun onStart(index: Int) {
// Compression start
}
override fun onSuccess(index: Int, size: Long, path: String?) {
if (path != null) {
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
onError("Compression Returned null")
}
}
override fun onFailure(index: Int, failureMessage: String) {
// keeps going with original video
onReady(uri, contentType, null)
}
override fun onCancelled(index: Int) {
onError("Compression Cancelled")
}
}
)
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) {
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
default(width = 640, format = Bitmap.CompressFormat.JPEG)
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} else {
onReady(uri, contentType, null)
}
}
fun from(uri: Uri?, contentType: String?, context: Context): File {
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val inputStream = context.contentResolver.openInputStream(uri!!)
val fileName: String = UUID.randomUUID().toString() + ".$extension"
val splitName: Array<String> = splitFileName(fileName)
val tempFile = File.createTempFile(splitName[0], splitName[1])
inputStream?.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(1024 * 50)
var read: Int = input.read(buffer)
while (read != -1) {
output.write(buffer, 0, read)
read = input.read(buffer)
}
}
}
return tempFile
}
private fun splitFileName(fileName: String): Array<String> {
var name = fileName
var extension = ""
val i = fileName.lastIndexOf(".")
if (i != -1) {
name = fileName.substring(0, i)
extension = fileName.substring(i)
}
return arrayOf(name, extension)
}
}
@@ -6,13 +6,13 @@ import android.util.Patterns
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
@@ -24,11 +24,15 @@ 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 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
@@ -39,8 +43,12 @@ 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.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -57,10 +65,9 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
@@ -82,6 +89,36 @@ 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 {
@@ -98,8 +135,8 @@ fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -122,13 +159,13 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -142,34 +179,53 @@ private fun RenderRegular(
}
}
val paragraphs = remember(content) {
content.split('\n').toImmutableList()
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed()
} else {
paragraph.trim().split(' ')
}
}
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
paragraphs.forEach { paragraph ->
RenderParagraph(paragraph, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderParagraph(
paragraph: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>
) {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed().toImmutableList()
} else {
paragraph.trim().split(' ').toImmutableList()
}
}
FlowRow() {
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
}
}
private fun parseUrls(
content: String,
tags: List<List<String>>
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
@@ -186,7 +242,7 @@ private fun parseUrls(
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
RichTextViewerState(
@@ -214,10 +270,10 @@ private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableList<List<String>>
tags: ImmutableListOfLists<String>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
@@ -238,7 +294,7 @@ private fun RenderWord(
WordType.BECH
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches()) {
if (tags.isNotEmpty()) {
if (tags.lists.isNotEmpty()) {
WordType.HASH_INDEX
} else {
WordType.OTHER
@@ -267,8 +323,8 @@ private fun RenderWordWithoutPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -300,8 +356,8 @@ private fun RenderWordWithPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -382,8 +438,10 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableList<List<String>>?, nav: (String) -> Unit) {
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,
@@ -392,16 +450,16 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
.compositeOver(backgroundColor.value)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
@@ -412,7 +470,7 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor)
.compositeOver(backgroundColor.value)
)
)
)
@@ -428,31 +486,29 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
Unit
}
MaterialRichText(
style = myMarkDownStyle
) {
RefreshableContent(content, tags) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
onLinkClicked = onClick
)
ProvideTextStyle(TextStyle(lineHeight = 1.30.em)) {
MaterialRichText(
style = myMarkDownStyle
) {
RefreshableContent(content, tags) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
onLinkClicked = onClick
)
}
}
}
}
@Composable
private fun RefreshableContent(content: String, tags: List<List<String>>?, onCompose: @Composable (String) -> Unit) {
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, onCompose: @Composable (String) -> Unit) {
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
val scope = rememberCoroutineScope()
ObserverAllNIP19References(content, tags) {
scope.launch(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
@@ -462,7 +518,7 @@ private fun RefreshableContent(content: String, tags: List<List<String>>?, onCom
}
@Composable
fun ObserverAllNIP19References(content: String, tags: List<List<String>>?, onRefresh: () -> Unit) {
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
@@ -490,9 +546,9 @@ fun ObserveNIP19(
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
onRefresh: suspend () -> Unit
onRefresh: () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseNote == null) {
@@ -507,13 +563,18 @@ private fun ObserveNIP19Event(
}
baseNote?.let { note ->
val noteState by note.live().metadata.observeAsState()
ObserveNote(note, onRefresh)
}
}
LaunchedEffect(key1 = noteState) {
if (noteState?.note?.event != null) {
launch(Dispatchers.IO) {
onRefresh()
}
@Composable
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
val noteState by note.live().metadata.observeAsState()
LaunchedEffect(key1 = noteState) {
if (noteState?.note?.event != null) {
launch(Dispatchers.IO) {
onRefresh()
}
}
}
@@ -524,7 +585,7 @@ private fun ObserveNIP19User(
it: Nip19.Return,
onRefresh: () -> Unit
) {
var baseUser by remember(it) { mutableStateOf<User?>(null) }
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
LaunchedEffect(key1 = it.hex) {
if (baseUser == null) {
@@ -539,19 +600,24 @@ private fun ObserveNIP19User(
}
baseUser?.let { user ->
val userState by user.live().metadata.observeAsState()
ObserveUser(user, onRefresh)
}
}
LaunchedEffect(key1 = userState) {
if (userState?.user?.info != null) {
launch(Dispatchers.IO) {
onRefresh()
}
@Composable
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
val userState by user.live().metadata.observeAsState()
LaunchedEffect(key1 = userState) {
if (userState?.user?.info != null) {
launch(Dispatchers.IO) {
onRefresh()
}
}
}
}
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
@@ -561,8 +627,8 @@ private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
@@ -602,7 +668,7 @@ private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>?
return null
}
private fun returnNIP19References(content: String, tags: List<List<String>>?): List<Nip19.Return> {
private fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -615,7 +681,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
}
}
tags?.forEach {
tags?.lists?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
@@ -628,14 +694,14 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<String>>?): String {
private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "$word "
returnContent += "![]($word) "
} else {
returnContent += "[$word]($word) "
}
@@ -706,7 +772,7 @@ fun startsWithNIP19Scheme(word: String): Boolean {
data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
@Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
LaunchedEffect(key1 = word) {
@@ -747,21 +813,21 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
private fun DisplayFullNote(
it: Note,
accountViewModel: AccountViewModel,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
loadedLink: LoadedBechLink
) {
val borderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
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 = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
borderColor,
RoundedCornerShape(15.dp)
QuoteBorder
)
}
@@ -866,13 +932,14 @@ fun HashTag(word: String, nav: (String) -> Unit) {
} ?: Text(text = "$word ")
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedTag by remember { mutableStateOf<LoadedTag?>(null) }
LaunchedEffect(key1 = word) {
if (baseUserPair == null && baseNotePair == null) {
if (loadedTag == null) {
launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
@@ -880,20 +947,20 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
Pair(null, "")
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
loadedTag = LoadedTag(it, null, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
loadedTag = LoadedTag(null, it, suffix)
}
}
}
@@ -902,55 +969,80 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
}
baseUserPair?.let {
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList()
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "${it.second} " },
tags = userTags,
route = route,
nav = nav
)
}
baseNotePair?.let {
if (canPreview) {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
if (loadedTag == null) {
Text(
text = remember {
"$word "
}
} else {
ClickableNoteTag(it.first, nav)
Text(text = "${it.second} ")
)
} else {
loadedTag?.user?.let {
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ")
loadedTag?.note?.let {
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
}
}
}
@Composable
private fun DisplayNoteFromTag(
baseNote: Note,
addedChars: String,
canPreview: Boolean,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit
) {
if (canPreview) {
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
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
addedChars.ifBlank { null }?.let {
Text(text = remember { "$it " })
}
} else {
ClickableNoteTag(baseNote, nav)
Text(text = remember { "$addedChars " })
}
}
@Composable
private fun DisplayUserFromTag(
baseUser: User,
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()
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "$addedChars " },
tags = userTags,
route = route,
nav = nav
)
}
@@ -12,6 +12,7 @@ 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
@@ -21,8 +22,6 @@ private fun toHex(color: Color): String {
return String.format("#%06X", rgb)
}
private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
private fun byteMod10(byte: Byte): Int {
val ub = byte.toUByte().toInt()
return ub % 10
@@ -33,7 +32,7 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
}
private fun svgString(msg: String): String {
val hash = sha256.digest(msg.toByteArray())
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])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
@@ -67,6 +66,7 @@ class HashImageFetcher(
) : Fetcher {
override suspend fun fetch(): SourceResult {
checkNotInMainThread()
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
@@ -35,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@Composable
fun SensitivityWarning(
@@ -108,7 +108,7 @@ fun ContentWarningNote(onDismiss: () -> Unit) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onDismiss,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -25,6 +25,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -68,7 +69,7 @@ fun DotsIndicator(
totalDots: Int,
selectedIndex: Int,
selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */,
unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */,
unSelectedColor: Color = MaterialTheme.colors.placeholderText /* Color.Gray */,
dotSize: Dp
) {
LazyRow(
@@ -31,13 +31,14 @@ 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 kotlinx.collections.immutable.ImmutableList
@Composable
fun TextSpinner(
label: String,
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -83,7 +84,12 @@ fun TextSpinner(
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(
options: ImmutableList<String>,
explainers: ImmutableList<String>?,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -8,56 +8,25 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
val context = LocalContext.current
var urlPreviewState by remember(url) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
mutableStateOf(default)
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
)
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url) {
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
} else {
urlPreviewState = UrlPreviewState.Empty
}
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error(
context.getString(
R.string.error_parsing_preview_for,
url,
throwable.message
)
)
}
}
)
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
}
}
}
}
@@ -6,7 +6,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.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@Composable
fun UrlPreviewCard(
@@ -32,11 +33,11 @@ fun UrlPreviewCard(
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column {
@@ -5,8 +5,16 @@ import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
@Immutable
sealed class UrlPreviewState {
@Immutable
object Loading : UrlPreviewState()
@Immutable
class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
@Immutable
object Empty : UrlPreviewState()
@Immutable
class Error(val errorMessage: String) : UrlPreviewState()
}
@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -27,6 +25,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,7 +44,6 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
@@ -55,31 +53,15 @@ import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
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.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
@@ -105,79 +87,116 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) {
if (loadingFinished.second != null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
} else {
VideoView(videoUri, description, onDialog)
VideoView(videoUri, description, null, onDialog)
}
}
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun VideoView(
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) }
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
val defaultVolume = remember { if (DefaultMutedSetting.value) 0f else 1f }
LaunchedEffect(key1 = videoUri) {
if (exoPlayer == null) {
if (exoPlayerData == null) {
launch(Dispatchers.Default) {
exoPlayer = ExoPlayer.Builder(context).build()
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
}
}
}
exoPlayer?.let {
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
}
it.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = defaultVolume
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
DisposableEffect(Unit) {
onDispose {
exoPlayerData?.exoPlayer?.release()
}
}
}
@Composable
fun VideoView(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
exoPlayerData.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = if (defaultToStart) 0f else 1f
if (videoUri.startsWith("file")) {
setMediaItem(media)
} else if (videoUri.endsWith("m3u8")) {
setMediaSource(
HlsMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
}
prepare()
)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
}
RenderVideoPlayer(it, context, thumb, onDialog)
prepare()
}
RenderVideoPlayer(exoPlayerData, thumb, onDialog)
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer?.pause()
exoPlayerData.exoPlayer.pause()
}
else -> {}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
lifecycle.addObserver(observer)
onDispose {
exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Stable
data class VideoPlayer(
val exoPlayer: ExoPlayer
)
@Stable
data class VideoThumb(
val thumb: Drawable?
)
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
playerData: VideoPlayer,
thumbData: VideoThumb?,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
BoxWithConstraints() {
AndroidView(
modifier = Modifier
@@ -185,27 +204,27 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
player = playerData.exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
playerData.exoPlayer.pause()
innerOnDialog(it)
}
}
@@ -216,7 +235,7 @@ private fun RenderVideoPlayer(
MuteButton() { mute: Boolean ->
DefaultMutedSetting.value = mute
exoPlayer.volume = if (mute) 0f else 1f
playerData.exoPlayer.volume = if (mute) 0f else 1f
}
}
}
@@ -0,0 +1,102 @@
package com.vitorpamplona.amethyst.ui.components
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
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
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.placeholderText
@Composable
fun ZapRaiserRequest(
titleText: String? = null,
newPostViewModel: NewPostViewModel
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = titleText ?: stringResource(R.string.zapraiser),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
Text(
text = stringResource(R.string.zapraiser_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
OutlinedTextField(
label = { Text(text = stringResource(R.string.zapraiser_target_amount_in_sats)) },
modifier = Modifier.fillMaxWidth(),
value = if (newPostViewModel.zapRaiserAmount != null) {
newPostViewModel.zapRaiserAmount.toString()
} else {
""
},
onValueChange = {
runCatching {
if (it.isEmpty()) {
newPostViewModel.zapRaiserAmount = null
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
}
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
}
}
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
@@ -49,7 +48,6 @@ 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.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -64,6 +62,7 @@ import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.imageLoader
@@ -74,7 +73,11 @@ 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.Nip05
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -164,7 +167,7 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -174,11 +177,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
if (content is ZoomableUrlContent) {
@@ -201,7 +204,10 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
}
}
if (dialogOpen) {
@@ -366,10 +372,9 @@ private fun aspectRatio(dim: String?): Float? {
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
delay(200)
cnt = content
}
@@ -442,7 +447,7 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -507,7 +512,9 @@ fun RenderImageOrVideo(content: ZoomableContent) {
LocalImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
content.localFile?.let {
VideoView(it.toUri().toString(), content.description)
}
}
}
}
@@ -565,7 +572,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
tint = MaterialTheme.colors.hashVerified,
modifier = Modifier.size(30.dp)
)
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.Note
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return account.userProfile().latestBookmarkList?.id ?: ""
}
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
@@ -7,6 +7,9 @@ import com.vitorpamplona.amethyst.model.Note
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return BookmarkPrivateFeedFilter.account.userProfile().latestBookmarkList?.id ?: ""
}
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
@@ -5,6 +5,11 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return channel.idHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel.notes
@@ -6,6 +6,10 @@ import com.vitorpamplona.amethyst.model.User
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feedKey(): String {
return withUser.pubkeyHex
}
override fun feed(): List<Note> {
val messages = account
.userProfile()
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -12,6 +11,10 @@ import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
@@ -84,7 +87,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
sort(myNewList.toSet()).take(1000)
}
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
@@ -1,9 +1,18 @@
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.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -26,4 +35,80 @@ class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
sort(myNewList.toSet()).take(1000)
}
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty()) {
emptySet()
} else {
newRelevantPrivateMessages.values.toSet()
}
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
if (roomUserHex != null &&
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
!account.isHidden(roomUserHex)
) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -18,6 +18,11 @@ abstract class FeedFilter<T> {
return feed.take(1000)
}
/**
* Returns a string that serves as the key to invalidate the list if it changes.
*/
abstract fun feedKey(): String
abstract fun feed(): List<T>
}
@@ -39,7 +44,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
}
}
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
}
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.service.model.*
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -12,6 +12,10 @@ object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + tag
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
@@ -1,9 +1,16 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag
}
override fun feed() = account.hiddenUsers()
override fun feed(): List<User> {
return (account.hiddenUsers + account.transientHiddenUsers)
.map { LocalCache.getOrCreateUser(it) }
}
}
@@ -7,6 +7,11 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -11,6 +12,10 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -26,14 +31,24 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val oneHr = 60 * 60
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
val noteEvent = it.event
(noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
it.isNewThread()
it.isNewThread() &&
(
(noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost
(
it.replyTo?.lastOrNull()?.author?.pubkeyHex !in followingKeySet ||
(noteEvent.createdAt() > (it.replyTo?.lastOrNull()?.createdAt() ?: 0) + oneHr)
) // or a repost of by a non-follower's post (likely not seen yet)
)
}
.toSet()
}
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -61,7 +65,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
if (event is RepostEvent) {
if (event is RepostEvent || event is GenericRepostEvent) {
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
@@ -8,6 +8,10 @@ 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()
@@ -7,6 +7,10 @@ import com.vitorpamplona.amethyst.model.ThreadAssembler
@Immutable
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
override fun feedKey(): String {
return noteId
}
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()

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