Compare commits

...
411 Commits
Author SHA1 Message Date
Vitor Pamplona 244e3f3b29 v0.61.2 2023-06-20 17:13:24 -04:00
Vitor Pamplona 6a44d283f9 - Restructures Chat Message Rendering
- Adds Sensitive Content protections for chat renderings.
- Adjusts padding of the channel headers.
2023-06-20 16:53:36 -04:00
Vitor Pamplona 6addce3d20 Simplifying some of the chat rendering process 2023-06-20 15:16:29 -04:00
Vitor Pamplona 8aae101ef6 Adds live activities to the main feed. 2023-06-20 13:54:47 -04:00
Vitor Pamplona d1bbdef5c4 v0.61.1 2023-06-19 22:01:30 -04:00
Vitor Pamplona b78967f8be Screen diet 2023-06-19 21:58:12 -04:00
Vitor Pamplona 01a2f746d0 Adds space between the play button and the user's name in private messages 2023-06-19 21:10:01 -04:00
Vitor Pamplona d063c2990c Moves the label to the bottom of the video 2023-06-19 20:58:38 -04:00
Vitor Pamplona e31ee031cb Removes the cache from streaming files to make sure the stream keeps playing. 2023-06-19 20:53:38 -04:00
Vitor Pamplona 8fc489cc59 Formatting 2023-06-19 20:43:55 -04:00
Vitor Pamplona ffb2a19963 Adds Zaps and Reactions to the Zap Activity author 2023-06-19 20:43:48 -04:00
Vitor Pamplona b271c1775c BugFix for not updating Notifications quickly. 2023-06-19 20:30:13 -04:00
Vitor Pamplona f937e95b35 v0.61.0 2023-06-19 18:36:32 -04:00
Vitor Pamplona 1613e79860 Support for live chats in streaming events. 2023-06-19 18:34:52 -04:00
Vitor Pamplona dd40b4f205 Formatting 2023-06-19 16:24:58 -04:00
Vitor Pamplona 2ad9401ee6 Downloads last posts from the author. 2023-06-19 16:23:19 -04:00
Vitor Pamplona 69ade2f6e6 Merge remote-tracking branch 'origin/HEAD' 2023-06-19 16:02:23 -04:00
Vitor Pamplona 4006e62a82 Renders Channel headers, NIP94 and NIP95 in the thread head. 2023-06-19 16:01:51 -04:00
Vitor Pamplona ec88a8b157 Updates readme 2023-06-19 16:00:23 -04:00
Vitor PamplonaandGitHub 128211ecd2 Merge pull request #459 from greenart7c3/main
add toast message to show relay icon description
2023-06-19 15:32:46 -04:00
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
greenart7c3 c35b5e3703 add toast message for relay icons 2023-06-19 07:58:25 -03: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
Vitor Pamplona e956f51f19 v0.55.2 2023-06-05 18:22:26 -04:00
Vitor Pamplona 165678f118 formatting 2023-06-05 18:22:09 -04:00
Vitor Pamplona d32d2da280 Increases the speed of the Zap Tab in Profiles. 2023-06-05 18:19:23 -04:00
Vitor PamplonaandGitHub d6a6a52821 Merge pull request #441 from davotoula/german_translation
Added German translation
2023-06-05 16:39:50 -04:00
Vitor Pamplona dfb697975f v0.55.1 2023-06-05 16:06:32 -04:00
Vitor Pamplona c0c0fd9e6d Fixes tests 2023-06-05 16:06:25 -04:00
Vitor Pamplona 5bdcdc6f4a - Refactoring feeds use of the pullrefresh
- Adding more places to check if in the main thread.
2023-06-05 15:33:16 -04:00
David Kaspar 102ab63673 Added German translation 2023-06-04 20:41:48 +01:00
Vitor Pamplona b34c1f98d5 Marks some methods to never run on the Main Thread 2023-06-04 12:17:19 -04:00
Vitor Pamplona 74b05e3d6a Simplifying recompositions of NIP05 2023-06-04 12:17:02 -04:00
Vitor Pamplona 972bce426a No need to display replying information anymore. 2023-06-04 11:56:53 -04:00
Vitor Pamplona 3de57d6ae2 Moves memory cleanup to an IO thread 2023-06-04 11:56:39 -04:00
Vitor Pamplona 5695a82c20 Fixes: https://github.com/vitorpamplona/amethyst/issues/438 2023-06-04 11:45:34 -04:00
Vitor Pamplona e171120836 Moves relay state to a new watcher 2023-06-03 21:46:26 -04:00
Vitor Pamplona abdfe531f7 v0.55.0 2023-06-03 18:50:12 -04:00
Vitor Pamplona ca90866877 Keeping the position of the feed in navigation 2023-06-03 17:02:09 -04:00
Vitor Pamplona 450fa2b70d Removing some unnecessary code 2023-06-03 16:21:02 -04:00
Vitor Pamplona 93c9ffeb02 adds a background to the app's icon in recommendation 2023-06-03 16:01:39 -04:00
Vitor Pamplona 2e5e546e7d Making sure video does not restart when pressing the mute button 2023-06-03 16:01:20 -04:00
Vitor Pamplona 00e7add001 Adds support for seeing NIP-89 recommendations on User's profile. 2023-06-03 12:39:06 -04:00
Vitor Pamplona e28cd2212e v0.54.2 2023-06-02 22:46:45 -04:00
Vitor Pamplona 8f38353271 Shows comment and amount of zaps sent directly to the profile. 2023-06-02 22:45:58 -04:00
Vitor Pamplona a41ee653d8 Moves reaction counts to a thread 2023-06-02 22:14:21 -04:00
Vitor Pamplona 839be06f62 Removes unnecessary mapping 2023-06-02 21:59:17 -04:00
Vitor Pamplona bbef06de59 Moves UserFeed to MutableLists 2023-06-02 21:52:22 -04:00
Vitor Pamplona 515159f18c Ignores unloaded notes in the MultiComposeSet renderer. 2023-06-02 21:29:12 -04:00
Vitor Pamplona e7b7ebbbdf Adds a new thread call for the notification of reaction rows 2023-06-02 21:28:50 -04:00
Vitor Pamplona 5965d9a3bb Moves profile watchers to their own compose functions 2023-06-02 21:28:30 -04:00
Vitor Pamplona 31959721b1 Moves reward calculations to the IO thread 2023-06-02 21:27:57 -04:00
Vitor Pamplona 8e16daaaa3 Only refreshes follow lists once per notification event. 2023-06-02 21:27:43 -04:00
Vitor Pamplona e9fd47a536 Runs bottom's new notification refresher in the IO thread 2023-06-02 21:27:11 -04:00
Vitor Pamplona 6191ef9a6b Removes old notification cards 2023-06-02 20:36:01 -04:00
Vitor Pamplona 05aa12ebac - Removing outdated version of the FlowRow
- Improving performance of custom emojis
- Improving performance of DropDown menus
2023-06-02 20:30:49 -04:00
Vitor Pamplona 42428c5e0f Moves channel checks to happen before a Channel object is loaded. 2023-06-01 19:56:31 -04:00
Vitor Pamplona 1a3685c5ab v0.54.1 2023-06-01 16:07:08 -04:00
Vitor Pamplona fff892e1c8 Making sure pollnote view models are unique 2023-06-01 16:03:22 -04:00
Vitor Pamplona 99c30816c6 Simplifying observables 2023-06-01 16:01:35 -04:00
Vitor Pamplona 92c61f317b Restructuring follows observables 2023-06-01 15:16:03 -04:00
Vitor Pamplona b0e50e0c00 Fixes Translations 2023-06-01 09:11:47 -04:00
Vitor Pamplona f065dcfc5a v0.54.0 2023-05-31 22:13:54 -04:00
Vitor Pamplona 91f34000f5 Improves rendering performance of the ExoPlayer 2023-05-31 22:09:18 -04:00
Vitor Pamplona 93e2af3c1f formatting 2023-05-31 21:32:53 -04:00
Vitor Pamplona 6072e5977e Reducing recompositions on images. 2023-05-31 21:32:46 -04:00
Vitor Pamplona 8575ea018d Improving rendering of the text 2023-05-31 20:43:57 -04:00
Vitor Pamplona ed919ae531 UrlPreviewer using Main thread :( 2023-05-31 20:08:30 -04:00
Vitor Pamplona 3eb832c4e0 Improves Poll Rendering speed. 2023-05-31 20:08:07 -04:00
Vitor Pamplona a57ca2a79d Using the correct immutable class 2023-05-31 16:33:23 -04:00
Vitor Pamplona 07a681c002 Merge remote-tracking branch 'origin/HEAD' 2023-05-31 16:31:55 -04:00
Vitor PamplonaandGitHub cb561d432c Merge pull request #433 from davotoula/add_per_app_language_preferences
Add support for per-app languange preferences
2023-05-31 16:31:19 -04:00
Vitor Pamplona d942c12627 Restructuring ViewModels 2023-05-31 16:30:11 -04:00
David Kaspar a466f33b4e Add support for per-app languange preferences 2023-05-31 09:34:21 +01:00
Vitor PamplonaandGitHub 3744f90e87 Merge pull request #432 from davotoula/translation_sv
Added Swedish translation
2023-05-30 16:04:34 -04:00
Vitor Pamplona 1cc0f4bf80 Don't notify twice 2023-05-30 16:02:45 -04:00
David Kaspar dec409c209 moved previous Swedish translation to a resource file
Corrected and added to Swedish translation
2023-05-30 20:58:50 +01:00
Vitor Pamplona c664397b11 Starts video with the audio on/off based on global mute variable 2023-05-30 13:58:21 -04:00
Vitor Pamplona bc1b71e9bf Improves reply info rendering 2023-05-30 13:58:00 -04:00
Vitor Pamplona a42606af0d v0.53.7 2023-05-30 12:07:23 -04:00
Vitor Pamplona b301e1d79b Removes sorting of the blocked list because it needs to load all metadata to sort correctly. 2023-05-30 12:07:15 -04:00
Vitor Pamplona 5ab94e8a1c - Renders the report's comment in the user profile report tab
- Allows reply, zap, boost and like the report itself.
- Shows report messages on notifications
2023-05-30 12:03:33 -04:00
Vitor Pamplona 7ad982ec71 v0.53.6 2023-05-29 21:44:11 -04:00
Vitor Pamplona 030064e112 Sorting blocked users by name 2023-05-29 21:43:59 -04:00
Vitor Pamplona d9a392d2c1 BugFix for not loading the security filter screen 2023-05-29 21:40:44 -04:00
Vitor Pamplona 97cd1c5e30 v0.53.5 2023-05-29 21:15:10 -04:00
Vitor Pamplona 24a6d8a155 Restart Video Cache when chancing Tor Setup 2023-05-29 21:14:06 -04:00
Vitor Pamplona ea185147b3 v0.54.4 2023-05-29 19:47:02 -04:00
Vitor Pamplona f2db2b88c2 Improves rendering of Markdown summaries 2023-05-29 19:46:54 -04:00
Vitor Pamplona 6ea0972151 Fixes bug with rendering of markdown with user citations. 2023-05-29 19:20:33 -04:00
Vitor Pamplona 093705f3b6 v0.52.3 2023-05-29 18:06:42 -04:00
Vitor Pamplona 1972297755 adds flavor to the version descriptor in the bottom left of the drawer 2023-05-29 18:06:35 -04:00
Vitor Pamplona ac00694690 Moves ExoPlayer to OkHttp in order to enable Tor support. 2023-05-29 18:00:29 -04:00
Vitor Pamplona 605861dc32 v0.53.2 2023-05-29 17:12:04 -04:00
Vitor Pamplona 43d508ba33 Correcting the user's room for the notification marker 2023-05-29 17:11:56 -04:00
Vitor Pamplona e692647d58 v0.53.1 2023-05-29 16:26:16 -04:00
Vitor Pamplona 64f98e4b92 Filters Message Feed DAL to only take PrivateMessages in the Notification Bubble. 2023-05-29 16:25:35 -04:00
Vitor Pamplona c58c20cc54 Adding immutable tag to ResizeImage class 2023-05-29 15:15:20 -04:00
Vitor Pamplona 89ddb7f4b7 v0.53.0 2023-05-29 15:05:01 -04:00
Vitor Pamplona cb92a51191 Adds separate colors for the newItem background between light and dark themes 2023-05-29 14:58:10 -04:00
Vitor PamplonaandGitHub 213b172c32 Merge pull request #431 from davotoula/add_translation_cs
Add Czech translations
2023-05-29 14:27:01 -04:00
Vitor Pamplona cfdd470626 formatting 2023-05-29 14:25:43 -04:00
Vitor Pamplona a5a3c62edd Moving chatroomlist to an additive filter 2023-05-29 14:25:34 -04:00
David Kaspar c9bcfa1c17 added Czech translation 2023-05-29 18:36:51 +01:00
Vitor Pamplona 02ad85a740 Avoids running through the entire filter when collection is Empty 2023-05-28 17:53:08 -04:00
Vitor Pamplona 446273de80 Adds mute control per track on the screen. 2023-05-28 17:36:20 -04:00
Vitor Pamplona b513877552 Optimizing UserProfile status 2023-05-28 17:33:01 -04:00
Vitor Pamplona 013eedd892 Removing logs 2023-05-28 17:32:48 -04:00
Vitor Pamplona d2cbb27756 Separating logs for full feed updates and additive feed updates 2023-05-28 17:32:40 -04:00
Vitor Pamplona 1a477ea1c8 Refreshing user status when added to the transient spam list 2023-05-28 17:32:05 -04:00
Vitor PamplonaandGitHub 83df639aff Merge pull request #430 from malimahda/main
changes to translation
2023-05-28 16:25:10 -04:00
Vitor Pamplona f298cc6f13 Adds some documentation to make sure any NIP05 changes take into account that we are converting the entire json to lowercase. 2023-05-28 16:24:37 -04:00
Vitor Pamplona 300a70ce18 Merge remote-tracking branch 'origin/HEAD' 2023-05-28 16:09:54 -04:00
Vitor PamplonaandGitHub 0f6bf1cc59 Merge pull request #428 from davotoula/bugfix_case_sensitive_nip05
Bugfix case sensitive nip05
2023-05-28 16:09:37 -04:00
Vitor Pamplona 6bc1b6b28a Loading reply from launch and not withContent 2023-05-28 16:03:15 -04:00
Vitor Pamplona 1c381f64f9 v0.52.3 2023-05-28 15:35:46 -04:00
Vitor Pamplona 5e3703e849 Moves all note states to leaf compositors 2023-05-28 15:34:13 -04:00
Vitor Pamplona acbd41ce61 Better recomposition structure for ReactionsRow 2023-05-28 15:17:10 -04:00
Vitor Pamplona 056d00b73b Performance Improvements when rendering a channel creation note 2023-05-28 15:16:50 -04:00
LandGitHub 72fc21cdc6 Update strings.xml
translated new strings
2023-05-28 21:22:33 +03:30
LandGitHub 3e983cdf1c Update strings.xml
added new strings
2023-05-28 20:40:16 +03:30
LandGitHub d81af9a194 Merge branch 'vitorpamplona:main' into main 2023-05-28 17:59:35 +03:30
David Kaspar 7f6b44cd0b spelling fix 2023-05-27 23:51:13 +01:00
David Kaspar 88bad1e308 added additional trivial tests 2023-05-27 22:35:34 +01:00
David Kaspar aa0a4d1aac Use lowercase versions of user name and retrieved json to make case insensitive match 2023-05-27 21:56:57 +01:00
David Kaspar b0a61cce42 Added failing test
(a bit dirty using a spy for the actual "system under test" but it is the quickest way to test added functionality and mocking out complexity around coroutines and httpclient)
2023-05-27 21:42:12 +01:00
Vitor Pamplona b3be4567e2 v0.52.2 2023-05-27 16:36:36 -04:00
Vitor Pamplona c5a586ec56 Fixes Missing Notifications 2023-05-27 16:36:27 -04:00
Vitor Pamplona ece3664e74 Merge remote-tracking branch 'origin/HEAD' 2023-05-27 16:34:42 -04:00
Vitor PamplonaandGitHub 2297c797dc Merge pull request #427 from ZsZolee/main
Sensitive content Hungarian translation
2023-05-27 16:24:48 -04:00
Vitor Pamplona 4517ca9fdd trimming urls as well 2023-05-27 16:16:52 -04:00
Vitor Pamplona 1146b8ad54 v0.52.1 2023-05-27 16:06:15 -04:00
Vitor Pamplona 545643d165 Fixes Relay Icon rendering. 2023-05-27 16:04:20 -04:00
ZoltanandGitHub 359e22ae42 Sensitive content Hungarian translation 2023-05-27 17:02:15 +02:00
Vitor Pamplona 69be28ec98 Making sure the UserReactonsRow in notifications get updated as new events come in. 2023-05-26 18:05:35 -04:00
Vitor Pamplona a8ffbb922d Moves relay color filters to a singleton 2023-05-26 14:03:57 -04:00
Vitor Pamplona bd4a450336 v0.52.0 2023-05-26 12:21:10 -04:00
Vitor Pamplona 39818268ab Support for NIP-36, declaration of sensitive content. 2023-05-26 12:20:17 -04:00
Vitor Pamplona d26b83a851 Updating kotlin dependencies. 2023-05-26 12:19:38 -04:00
Vitor Pamplona 8dc9c2ed2f 0.51.4 2023-05-26 09:37:51 -04:00
Vitor Pamplona 860f3bd096 Improves color contrast for Zap Amount notifications in light mode 2023-05-26 09:00:05 -04:00
Vitor Pamplona a3adadb251 0.51.3 2023-05-25 21:09:47 -04:00
Vitor Pamplona 755d89dc79 Only changing stats for the Reaction Row if the data actually changed. 2023-05-25 21:09:14 -04:00
Vitor Pamplona fa4463f1d7 Improves rendering of messages in the Notification feed 2023-05-25 21:08:20 -04:00
Vitor Pamplona 56a68b9100 Feed invalidation is necessary for lists 2023-05-25 20:40:04 -04:00
Vitor Pamplona fe317964b8 Simplifying Private message rendering scheme 2023-05-25 20:39:15 -04:00
Vitor Pamplona 07ccf75d44 updates jsoup and activity compose to the latest versions 2023-05-25 18:43:01 -04:00
Vitor Pamplona ff927d4830 v0.51.2 2023-05-25 18:38:34 -04:00
Vitor Pamplona 6461c3e498 Changes the structure of the notification view to increase speed. 2023-05-25 18:35:23 -04:00
Vitor Pamplona 2ed5eae029 Preserves invalidations of DataSources for Home, Videos and Notifications 2023-05-25 18:34:40 -04:00
Vitor Pamplona 1693455d0d Only removes scrollTop from Uri after 1 second and to avoid changing before composition finishes. 2023-05-25 18:33:22 -04:00
Vitor Pamplona 3bcda33fa6 Activates invalidations that can be ignored if another invalidation is already running. 2023-05-25 18:30:42 -04:00
Vitor Pamplona 9f394900a7 Only scrolls to the top if not at the top 2023-05-25 18:30:15 -04:00
Vitor Pamplona 4f2c78e73a Remembers formatting for the Notification Reaction Row 2023-05-25 18:29:41 -04:00
Vitor Pamplona 6a3600fd73 Updates Notification lists in intervals of 1 second. 2023-05-25 18:28:51 -04:00
Vitor Pamplona b40c6f1361 remember modifiers to avoid recomposition 2023-05-25 18:28:34 -04:00
Vitor Pamplona 5ce19474ae Moves Home and Video screen invalidations to follow the scrollTop parameter 2023-05-25 18:28:19 -04:00
Vitor Pamplona e9bdc045ba Adds @Stable tag to Notification Card models 2023-05-25 18:27:05 -04:00
Vitor Pamplona aed7f46ca0 remembering size of the robohash picture 2023-05-25 18:25:47 -04:00
Vitor Pamplona 310a93ef29 removes logs 2023-05-25 16:24:16 -04:00
Vitor Pamplona 1ccaa1a028 Removing the start of relays before logging in. 2023-05-25 08:56:04 -04:00
Vitor Pamplona 493888d121 v0.51.1 2023-05-24 18:12:46 -04:00
Vitor Pamplona e5aba62fdd Imports BahaUrlPreview library to add the proxy connection into it. 2023-05-24 18:11:51 -04:00
Vitor PamplonaandGitHub b643cedf8f Merge pull request #424 from KotlinGeekDev/retain-state
Retain state when rotating device.
2023-05-24 17:02:27 -04:00
Vitor Pamplona 2c0bfe2118 v0.51.0 2023-05-24 17:01:30 -04:00
Vitor Pamplona f1ecaf1ee5 - Adds Immutable Lists to avoid recompositions
- Moves NavController to a lambda to avoid recompositions
- Moves AccountViewModel builder to a ViewModel Factory to avoid recompositions
- Reduces notifications invalidation requests when starting the screen.
2023-05-24 12:34:09 -04:00
Vitor Pamplona 4841d4004e Fixes https://github.com/vitorpamplona/amethyst/issues/425 2023-05-24 10:38:25 -04:00
KotlinGeek 75d56214d2 Use manifest to avoid Activity recreation. 2023-05-23 23:58:59 +01:00
Vitor Pamplona f0ba68e467 Fixing missing some notifications. 2023-05-23 16:04:29 -04:00
Vitor Pamplona 5a375b94d6 Adds Zap amounts to Notification bubbles. 2023-05-23 15:14:00 -04:00
Vitor Pamplona dcbd4148e2 v0.50.6 2023-05-23 12:14:47 -04:00
Vitor Pamplona 0015100dea Fixing Immutable imports 2023-05-23 12:12:43 -04:00
Vitor Pamplona 8898e5d10b Adds Immutable and Stable annotations to avoid recomposing 2023-05-23 12:02:34 -04:00
Vitor Pamplona 15621f16a2 Improves the speed of updating Notification notes 2023-05-23 10:37:34 -04:00
Vitor Pamplona a29d9ad433 Adds a cache layer for citations in Notes 2023-05-23 10:37:18 -04:00
Vitor Pamplona d355f22938 Fixes click on the Home to update the feed 2023-05-23 10:35:56 -04:00
Vitor Pamplona 2082c6cb28 Fixes size for following symbol 2023-05-22 21:01:25 -04:00
Vitor Pamplona 4b49cc1aab better translations to unfollow in portuguese 2023-05-22 20:47:34 -04:00
Vitor Pamplona 538e61b38e rollback vm version 2023-05-22 20:47:13 -04:00
Vitor Pamplona 93f8847544 moving plugin to the latest 2023-05-22 20:41:42 -04:00
Vitor Pamplona a1da4affeb Updating dependencies 2023-05-22 20:04:33 -04:00
Vitor Pamplona 0bf94f316f Moving more variables to remember clauses 2023-05-22 20:04:23 -04:00
Vitor Pamplona a3fc22a04a v0.50.5 2023-05-22 18:24:50 -04:00
Vitor Pamplona 6437a2106a Moves zapProgress to inside the IO update to make sure it only updates when needed. 2023-05-22 17:52:46 -04:00
Vitor Pamplona 0a8c79dacd Stops recomposing the drawer at every new route. 2023-05-22 17:52:18 -04:00
Vitor Pamplona 22dbaaf95a Adjusts bottombar to not recalculate hasNewItems in every recomposition. 2023-05-22 17:51:59 -04:00
Vitor Pamplona 278091e6ef Moves default state of url previews to the remember function 2023-05-22 17:51:37 -04:00
Vitor Pamplona d99711d3e6 Remembering Background color and Text elements for posts. 2023-05-22 17:51:12 -04:00
Vitor Pamplona 48f8cd7d95 Merge remote-tracking branch 'origin/HEAD' 2023-05-22 15:11:10 -04:00
Vitor Pamplona 3b816559e4 Optimizes MultiSetCompose 2023-05-22 15:10:54 -04:00
Vitor Pamplona e96a50b454 Optimizes recomposition of bottom items. 2023-05-22 15:08:14 -04:00
Vitor Pamplona 93282b71ae Optimizes NIP-05 procedures 2023-05-22 15:07:47 -04:00
Vitor Pamplona 8223476cb3 Formatting 2023-05-22 15:07:21 -04:00
Vitor Pamplona 460187c4a4 Optimizes Drawer for Recomposition 2023-05-22 15:07:13 -04:00
Vitor Pamplona 5da49c920b Removes the time log for Composing a note 2023-05-22 14:55:06 -04:00
Vitor Pamplona df5b5b90af Formatting 2023-05-22 14:54:42 -04:00
Vitor Pamplona e5b937ed7e Only updates the refreshing state if it was refreshing. 2023-05-22 14:54:25 -04:00
Vitor PamplonaandGitHub b4b90cfd19 Merge pull request #420 from KotlinGeekDev/back-navigation-fix
Improve navigation behaviour.
2023-05-22 14:35:50 -04:00
Vitor Pamplona 99d302802a Deprecating IMGUR 2023-05-22 09:50:06 -04:00
Vitor Pamplona ec2fc2f7e3 Updates relay UI on messages sent by the logged in user in chat 2023-05-22 09:49:34 -04:00
Vitor Pamplona 26634ccb04 Migrates notification summary to a new day without having to restart the app. 2023-05-22 09:18:13 -04:00
KotlinGeek 7766886587 Apply fix to profile navigation as well. 2023-05-22 12:56:17 +01:00
Vitor Pamplona b219b3631c - Adds hashtags and #[n] rendering to markdown
- Shortcuts the link to Amethyst's router.
2023-05-21 20:54:42 -04:00
Vitor Pamplona a0797f7104 Fixes NWC with Auth 2023-05-21 20:53:57 -04:00
Vitor Pamplona 330f3503a6 Adds nostr: prefix to channel id share. 2023-05-21 20:53:20 -04:00
KotlinGeek 7da87af0fb Verify presence of note in backstack before navigating to it. 2023-05-22 01:29:38 +01:00
Vitor Pamplona 2eb0096630 Adds a space in a bookmark author's name 2023-05-20 20:35:13 -04:00
LandGitHub 459e579bcb Update strings.xml
changed some word to better choices
2023-05-20 20:46:04 +03:30
LandGitHub 3c069d574e Merge branch 'vitorpamplona:main' into main 2023-05-20 20:38:19 +03:30
Vitor Pamplona 639569cd86 v0.50.4 2023-05-17 17:05:06 -04:00
Vitor Pamplona e41df98920 Bugfix for channel metadata messages show up before channel creation packages 2023-05-17 17:01:07 -04:00
Vitor Pamplona 323e71c7cb Bugfix for saving Replaceable notes in Bookmarks. 2023-05-17 15:47:20 -04:00
Vitor Pamplona cbe9462f28 v0.50.3 2023-05-17 13:55:30 -04:00
Vitor Pamplona 95febc1c8e Merge remote-tracking branch 'origin/main' 2023-05-17 13:55:17 -04:00
Vitor PamplonaandGitHub 5d8ffd1e2c Merge pull request #415 from KotlinGeekDev/hashtag-search-fix
Fix for case-sensitive hashtag issue.
2023-05-17 13:55:02 -04:00
Vitor PamplonaandGitHub f2616855c2 Merge pull request #414 from ZsZolee/main
Replacing some missing Hungarian translation
2023-05-17 13:54:50 -04:00
Vitor Pamplona 5040350be5 Fixes Auth for NIP-42 sporadic connections. 2023-05-17 13:54:20 -04:00
KotlinGeek d6c8651f0d Add lowercase variant of hashtag when creating event. 2023-05-17 17:26:31 +01:00
ZoltanandGitHub c5176b8c49 Replacing some missing Hungarian translation 2023-05-17 17:27:43 +02:00
Vitor Pamplona d239020895 v0.50.2 2023-05-17 11:20:14 -04:00
Vitor Pamplona 117446688b Fixes jumping chat screens on hidden posts. 2023-05-17 11:20:03 -04:00
Vitor Pamplona a8e650a38d v0.50.1 2023-05-17 09:50:43 -04:00
Vitor Pamplona 95a21cc08c Adds Navigation markers to the top panel and reaction watch 2023-05-17 09:49:31 -04:00
Vitor Pamplona 0823f2e7b5 Additive rendering of statistics 2023-05-17 09:34:21 -04:00
Vitor Pamplona cb42196889 Adds reaction watch. 2023-05-16 22:41:47 -04:00
Vitor Pamplona 6aadf8a883 0.49.4 2023-05-16 19:00:44 -04:00
Vitor Pamplona bb9fa8e144 Adding the missing search element 2023-05-16 18:51:08 -04:00
Vitor Pamplona 5a3cf40402 Adds online search when typing the username in the new Post field. 2023-05-16 18:43:58 -04:00
Vitor Pamplona 1519e70b24 Increasing size of the "More Options" button in the Video Feed. 2023-05-16 18:03:24 -04:00
Vitor Pamplona 135596591a Optimizes the code for chat bubble rendering 2023-05-16 17:53:36 -04:00
Vitor Pamplona 389afcb600 Fixes Robohash dependency in size 2023-05-16 17:53:15 -04:00
Vitor Pamplona d6c2966792 Adds color to relay icons 2023-05-16 17:53:02 -04:00
Vitor Pamplona dd8b208a0d Avoiding creating a lnurl link with just those words 2023-05-16 17:52:39 -04:00
Vitor Pamplona 27182a77a4 v0.49.3 2023-05-16 12:25:16 -04:00
Vitor Pamplona cf5f345d54 Formatting 2023-05-16 12:20:35 -04:00
Vitor Pamplona 3950517743 Improves rendering performance for chats. 2023-05-16 12:20:27 -04:00
LandGitHub d2accf7622 Update strings.xml
a few words . not urgent
2023-05-16 19:22:04 +03:30
Vitor Pamplona 7a14a1ab26 Formatting 2023-05-16 11:30:54 -04:00
Vitor Pamplona 9e93a35ee7 Parsing NIP-19 to improve search results. 2023-05-16 11:30:43 -04:00
Vitor Pamplona 76df70c6f6 Making sure filters return similar orders when two posts are published at the same time. 2023-05-16 11:10:17 -04:00
Vitor Pamplona 1d5ff5bea9 v0.49.2 2023-05-16 10:24:45 -04:00
Vitor Pamplona 1767d4a3a3 Enabling a log to help NIP95 relay developers track what the app is doing with their images. 2023-05-16 10:24:05 -04:00
Vitor Pamplona f2d87b78b1 Fixing emoji's on chat handles 2023-05-16 10:12:31 -04:00
Vitor Pamplona 4bf76d1b79 v0.49.1 2023-05-16 09:47:18 -04:00
Vitor Pamplona 17fbf8c01f BugFix for incorrect caching of NIP95 2023-05-16 09:47:10 -04:00
Vitor Pamplona 332490376b Adjustments to Markdown detection and rendering. 2023-05-16 08:52:59 -04:00
Vitor Pamplona 14e670cd9a Avoid color-filtering the emoji 2023-05-16 08:32:25 -04:00
Vitor Pamplona d29e6b4d0b Better shade for Pins 2023-05-16 08:32:03 -04:00
Vitor Pamplona 3558bab786 v0.49.0 2023-05-15 22:18:21 -04:00
Vitor Pamplona 7c21f077db Support for PinStr (33888) 2023-05-15 22:18:12 -04:00
Vitor Pamplona a2a9d04e1e v0.48.0 2023-05-15 21:35:55 -04:00
Vitor Pamplona fc56f3988e Support for NIP-30 2023-05-15 21:26:59 -04:00
Vitor PamplonaandGitHub 95ad33ace2 Merge pull request #412 from Pextar/main
Swedish translation
2023-05-15 14:11:11 -04:00
Vitor PamplonaandGitHub cf850415b8 Merge branch 'main' into main 2023-05-15 14:11:06 -04:00
Vitor Pamplona bd77bb7780 Merge remote-tracking branch 'origin/HEAD' 2023-05-15 14:09:56 -04:00
Vitor Pamplona 6a696bfa39 Adds a 5 minute expiration time for ImageErrors 2023-05-15 14:09:43 -04:00
PextarandGitHub d5fc97184c Merge branch 'main' into main 2023-05-15 20:07:43 +02:00
PextarandGitHub e0b9120e17 Create values-sv 2023-05-15 20:06:08 +02:00
Vitor PamplonaandGitHub 1523a29a4f Merge pull request #410 from greenart7c3/main
add longPress to edit tor/orbot settings
2023-05-15 13:51:25 -04:00
Vitor Pamplona 1db6b4793f Adds NIP-94 shareable link to filestr 2023-05-15 13:25:21 -04:00
Vitor Pamplona 426a495eb4 Adds zapstr.live and listr.lol as shareable links 2023-05-15 13:17:31 -04:00
Vitor Pamplona e07b4d49ef Fixes NPE on opening the QuickAction on a "Post Not Found" event. 2023-05-15 13:10:33 -04:00
Vitor Pamplona 595cab3630 v0.47.0 2023-05-15 11:16:50 -04:00
Vitor Pamplona 817a9c3e8d Adds support for AudioTrack Types from (https://zapstr.live/) 2023-05-15 11:15:32 -04:00
greenart7c3 32927040e3 add longPress to edit tor/orbot settings 2023-05-15 11:17:42 -03:00
Vitor Pamplona c973d941cc v0.46.6 2023-05-14 22:23:37 -04:00
Vitor Pamplona db6ebe609c Forces aspect ratio while obeying maxWidth and Height of the frame. 2023-05-14 22:22:53 -04:00
Vitor Pamplona 32913915ce Making sure remember does not interfere with translations. 2023-05-14 22:22:22 -04:00
Vitor Pamplona 4a8680e582 0.46.5 2023-05-14 21:06:06 -04:00
Vitor Pamplona 7cf0291b5a Applies remember functions to more parameters. 2023-05-14 21:05:58 -04:00
Vitor Pamplona 0406632e9d Moves internal content changes to derived states. 2023-05-14 20:24:41 -04:00
Vitor Pamplona bf2949717c Makes sure to only change the visible status when needed. 2023-05-14 20:24:11 -04:00
PextarandGitHub fc1ee54548 Merge branch 'vitorpamplona:main' into main 2023-05-14 08:29:09 +02:00
PextarandGitHub 754ac9894b Delete values-sv 2023-05-12 20:38:08 +02:00
PextarandGitHub e80c8b37b3 Create values-sv 2023-05-12 12:14:20 +02:00
266 changed files with 18515 additions and 7659 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
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
<option name="version" value="1.8.21" />
</component>
</project>
+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
+19 -8
View File
@@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
@@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Lightning Tips
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Created_at Limits (NIP-22)
- [x] Event Deletion (NIP-09)
- [x] Identity Verification (NIP-05)
- [x] Long-form Content (NIP-23)
- [x] Parameterized Replaceable Events (NIP-33)
@@ -54,23 +55,33 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [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)
- [x] Generic Reposts (kind:16)
- [x] Live Activities (NIP-102)
- [ ] 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)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Account Creation / Backup Guidance
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] OpenTimestamps Attestations (NIP-03)
# Development Overview
+36 -15
View File
@@ -13,17 +13,19 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 158
versionName "0.46.4"
versionCode 218
versionName "0.61.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
}
buildTypes {
release {
// TODO: Make sure all of JSON parsers work when activating these.
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
@@ -71,7 +73,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
kotlinCompilerExtensionVersion "1.4.7"
}
packagingOptions {
@@ -82,12 +84,11 @@ android {
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -128,17 +129,20 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'org.jsoup:jsoup:1.16.1'
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
// important for proxy / tor
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -150,8 +154,6 @@ dependencies {
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
@@ -163,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'
@@ -174,11 +181,25 @@ 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
implementation "com.patrykandpatrick.vico:core:${vico_version}"
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
// 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.10'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
@@ -1,9 +1,10 @@
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 androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -11,10 +12,10 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) = ExpandableRichTextViewer(
content,
canPreview,
@@ -22,5 +23,5 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
navController
nav
)
+20 -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
@@ -30,11 +46,13 @@
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
@@ -30,6 +31,7 @@ import java.util.Locale
private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Immutable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean = false
@@ -47,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"
@@ -58,6 +61,10 @@ private object PrefKeys {
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
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" }
}
@@ -203,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)
@@ -214,6 +222,15 @@ 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)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
}
@@ -245,15 +262,20 @@ 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
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
) ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
@@ -292,44 +314,57 @@ object LocalPreferences {
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} 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
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,52 +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 {
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) {
if (hasActiveObservers()) {
postValue(NotificationState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
}
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
}
}
@@ -1,5 +1,13 @@
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
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
@@ -15,19 +23,41 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
fun start(account: Account, context: Context) {
this.account = account
start()
start(context)
}
fun start() {
@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) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
// Initializes video cache.
VideoCache.init(context.applicationContext)
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
@@ -46,13 +76,12 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}
fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.vitorpamplona.amethyst.service.HttpClient
object VideoCache {
@@ -18,6 +19,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -31,7 +33,14 @@ object VideoCache {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} else {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
@@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import android.util.Log
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.*
@@ -43,6 +46,7 @@ val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
@@ -52,8 +56,9 @@ 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.IMGUR,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
@@ -62,17 +67,33 @@ class Account(
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int
var proxyPort: Int,
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())
@@ -82,11 +103,7 @@ class Account(
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun isWriteable(): Boolean {
@@ -129,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 {
@@ -141,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)
}
@@ -217,7 +234,12 @@ class Account(
zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
val wcListener = NostrLnZapPaymentResponseDataSource(
fromServiceHex = nip47.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
authSigningKey = nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!
)
wcListener.start()
LocalCache.consume(event, zappedNote) {
@@ -309,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)
}
}
}
@@ -472,7 +500,18 @@ class Account(
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: String? = null) {
fun sendPost(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>
) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -486,6 +525,11 @@ class Account(
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
replyingTo = replyingTo,
root = root,
directMentions = directMentions,
privateKey = loggedIn.privKey!!
)
@@ -502,7 +546,9 @@ class Account(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null
) {
if (!isWriteable()) return
@@ -521,14 +567,16 @@ class Account(
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver
zapReceiver = zapReceiver,
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) {
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 }
@@ -541,26 +589,50 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null) {
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = LiveActivitiesChatMessageEvent.create(
message = message,
activity = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
recipientPubKey = toUser.pubkey(),
publishedRecipientPubKey = toUser.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -593,18 +665,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -615,18 +702,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -637,18 +739,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -665,18 +782,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.minus(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -767,6 +899,12 @@ class Account(
saveable.invalidateData()
}
fun changeReactionTypes(newTypes: List<String>) {
reactionChoices = newTypes
live.invalidateData()
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Nip47URI?) {
zapPaymentRequest = newServer
live.invalidateData()
@@ -828,15 +966,7 @@ class Account(
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -931,10 +1061,10 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
if (newContactList == null || newContactList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
if (backupContactList?.id != newContactList.id) {
backupContactList = newContactList
saveable.invalidateData()
}
@@ -989,12 +1119,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
}
@@ -1007,7 +1144,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
}
@@ -1015,7 +1152,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()
@@ -1046,6 +1183,25 @@ class Account(
saveable.invalidateData()
}
fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show
saveable.invalidateData()
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 {
@@ -1056,7 +1212,9 @@ class Account(
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
GlobalScope.launch(Dispatchers.IO) {
updateContactListTo(userProfile().latestContactList)
}
}
// imports transient blocks due to spam.
@@ -1067,6 +1225,7 @@ class Account(
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
}
}
@@ -1086,14 +1245,14 @@ class Account(
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Default) {
if (hasActiveObservers()) {
refresh()
}
}
private val bundler = BundledUpdate(300, Dispatchers.Default)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
refresh()
}
}
}
fun refresh() {
@@ -1101,4 +1260,5 @@ class AccountLiveData(private val account: Account) : LiveData<AccountState>(Acc
}
}
@Immutable
class AccountState(val account: Account)
@@ -3,7 +3,10 @@ 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
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -16,11 +19,20 @@ class AntiSpamFilter {
@Synchronized
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.
@@ -28,6 +40,7 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
@@ -55,14 +68,14 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
}
}
@@ -1,28 +1,108 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
class Channel(val idHex: String) {
var creator: User? = null
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info.about
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info?.summary()
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun summary(): String? {
return null
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun addNote(note: Note) {
@@ -33,23 +113,11 @@ class Channel(val idHex: String) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
fun removeNote(noteHex: String) {
notes.remove(noteHex)
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
@@ -57,7 +125,7 @@ class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
.toSet()
@@ -74,24 +142,32 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
}
@@ -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
}
}
@@ -50,6 +50,23 @@ fun decodePublicKey(key: String): ByteArray {
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex
if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key).toHexKey()
}
} catch (e: Exception) {
null
}
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
@@ -1,11 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
@@ -13,7 +13,6 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -23,12 +22,6 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>(5000)
@@ -40,6 +33,8 @@ object LocalCache {
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
}
@@ -48,6 +43,8 @@ object LocalCache {
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
@@ -55,18 +52,41 @@ 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()
if (ATag.isATag(key)) {
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
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
@@ -75,8 +95,18 @@ object LocalCache {
}
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
}
val aTag = ATag.parse(key, null)
if (aTag != null) {
return getOrCreateChannel(aTag.toTag()) {
LiveActivitiesChannel(aTag)
}
}
return null
}
@@ -92,9 +122,11 @@ object LocalCache {
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
val answer = channelFactory(key)
channels.put(key, answer)
answer
}
@@ -115,33 +147,35 @@ 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)
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue(
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
return
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
oldUser.updateUserInfo(newUserMetadata, event)
}
oldUser.updateUserInfo(newUser, event)
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -161,9 +195,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
@@ -216,9 +256,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)
@@ -281,10 +327,98 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
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())
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
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)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
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
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
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
@@ -296,9 +430,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
@@ -333,6 +473,46 @@ object LocalCache {
refreshObservers(note)
}
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
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
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
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@@ -405,8 +585,13 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
deleteNote.channelHex()?.let {
channels[it]?.removeNote(deleteNote)
}
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
channels[it.toTag()]?.removeNote(deleteNote)
}
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
@@ -454,6 +639,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)
@@ -468,26 +678,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)
}
}
if (event.content == "!" || // nostr_console hide.
event.content == "\u26A0\uFE0F" // Warning sign
) {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
repliesTo.forEach {
it.addReaction(note)
}
refreshObservers(note)
@@ -517,9 +709,15 @@ object LocalCache {
mentions.forEach {
it.addReport(note)
}
}
repliesTo.forEach {
it.addReport(note)
} else {
repliesTo.forEach {
it.addReport(note)
}
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
}
}
refreshObservers(note)
@@ -527,20 +725,27 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing
val oldChannel = getOrCreateChannel(event.id) {
PublicChatChannel(it)
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
}
fun consume(event: ChannelMetadataEvent) {
@@ -550,20 +755,25 @@ object LocalCache {
// new event
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: ChannelMessageEvent, relay: Relay?) {
@@ -609,6 +819,47 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
val activityId = event.activity() ?: return
val channel = getOrCreateChannel(activityId.toTag()) {
LiveActivitiesChannel(activityId)
}
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelHideMessageEvent) {
}
@@ -739,19 +990,18 @@ object LocalCache {
note.addRelay(relay)
}
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
if (!file.exists()) {
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
val stream = FileOutputStream(file)
stream.write(event.decode())
stream.close()
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
}
} catch (e: IOException) {
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// Already processed this event.
@@ -811,6 +1061,14 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
return listOfNotNull(users[key])
}
return users.values.filter {
(it.anyNameStartsWith(username)) ||
it.pubkeyHex.startsWith(username, true) ||
@@ -819,6 +1077,18 @@ object LocalCache {
}
fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && (notes[key] ?: addressables[key]) != null) {
return listOfNotNull(notes[key] ?: addressables[key])
}
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
@@ -834,6 +1104,18 @@ object LocalCache {
}
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && channels[key] != null) {
return listOfNotNull(channels[key])
}
return channels.values.filter {
it.anyNameStartsWith(text) ||
it.idHex.startsWith(text, true) ||
@@ -852,6 +1134,8 @@ object LocalCache {
}
fun pruneOldAndHiddenMessages(account: Account) {
checkNotInMainThread()
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
@@ -865,11 +1149,13 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
}
}
fun pruneHiddenMessages(account: Account) {
checkNotInMainThread()
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
}.flatten()
@@ -897,6 +1183,8 @@ object LocalCache {
}
fun pruneContactLists(userAccount: Account) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
@@ -915,11 +1203,23 @@ object LocalCache {
live.invalidateData(newNote)
}
fun consume(event: Event, relay: Relay?) {
if (!event.hasValidSignature()) return
fun verifyAndConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
}
try {
when (event) {
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
@@ -936,9 +1236,11 @@ object LocalCache {
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -948,13 +1250,21 @@ object LocalCache {
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
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)
}
@@ -1,12 +1,16 @@
package com.vitorpamplona.amethyst.model
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
@@ -20,6 +24,7 @@ import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
@@ -28,6 +33,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
}
@Stable
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
@@ -38,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
@@ -67,10 +73,8 @@ open class Note(val idHex: String) {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
}
fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
}
open fun address(): ATag? = null
@@ -95,7 +99,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 +112,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 +137,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 +195,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 +207,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 +218,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()
}
}
@@ -228,7 +251,7 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
@@ -240,12 +263,14 @@ open class Note(val idHex: String) {
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author === user }
fun isReactedBy(user: User): String? {
return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
}.keys.firstOrNull()
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author === user }
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
@@ -266,6 +291,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 +375,14 @@ 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
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
@@ -358,7 +394,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 +410,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 {
@@ -410,14 +486,14 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
}
override fun onActive() {
@@ -439,4 +515,5 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
}
@Immutable
class NoteState(val note: Note)
@@ -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,46 +1,56 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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)
onReady(it)
return
}
failures[url]?.let {
callback?.onFailed(it)
return
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
}
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
).fetchUrlPreview()
}
override fun onFailed(throwable: Throwable) {
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
}
).fetchUrlPreview()
}
}
@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -20,6 +23,7 @@ import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
@@ -92,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()
@@ -182,6 +186,8 @@ class User(val pubkeyHex: String) {
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -191,6 +197,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun addMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
@@ -200,6 +208,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
@@ -246,15 +256,11 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
return latestContactList?.isTaggedHash(tag) ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
@@ -269,11 +275,17 @@ class User(val pubkeyHex: String) {
} ?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
} ?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowerCount(): Int {
suspend fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
@@ -289,14 +301,14 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this === it.author }
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -328,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)
@@ -339,6 +352,7 @@ class UserLiveSet(u: User) {
fun isInUse(): Boolean {
return follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
@@ -350,6 +364,7 @@ class UserLiveSet(u: User) {
}
}
@Immutable
data class RelayInfo(
val url: String,
var lastEvent: Long,
@@ -358,6 +373,7 @@ data class RelayInfo(
data class Chatroom(var roomMessages: Set<Note>)
@Stable
class UserMetadata {
var name: String? = null
var username: String? = null
@@ -384,9 +400,13 @@ class UserMetadata {
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
}
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? {
@@ -396,14 +416,14 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
}
override fun onActive() {
@@ -417,4 +437,5 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
}
@Immutable
class UserState(val user: User)
@@ -20,6 +20,8 @@ class BlurHashFetcher(
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service
import android.os.Looper
import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() {
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
}
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
class OnMainThreadException(str: String) : RuntimeException(str)
@@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.service
import java.util.regex.Pattern
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}
@@ -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,19 +70,25 @@ class Nip05Verifier() {
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
// checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url = try {
mapper.readTree(it)
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val user = nip05.split("@")[0].lowercase()
val hexKey = nip05url?.get("names")?.get(user)?.asText()
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400
)
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
@@ -88,6 +98,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
@@ -95,7 +106,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
@@ -112,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
}
@@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -12,9 +14,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(account: Account, channelId: String) {
fun loadMessagesBetween(account: Account, channel: Channel) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
this.channel = channel
resetFilters()
}
@@ -26,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
@@ -38,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 50
)
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
@@ -52,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 200
)
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = LocalCache.users[userId]
withUser = user
resetFilters()
}
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.consume(event, relay)
LocalCache.verifyAndConsume(event, relay)
}
}
@@ -103,16 +103,16 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate()
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
fun resetFilters() {
@@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFiltersSuspend() {
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -13,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
limit = 200
)
)
@@ -2,9 +2,15 @@ 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.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
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
@@ -56,7 +62,18 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(
TextNoteEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
LongTextNoteEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -72,7 +89,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -1,14 +1,18 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrLnZapPaymentResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigningKey: ByteArray
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
@@ -36,4 +40,14 @@ class NostrLnZapPaymentResponseDataSource(
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
Client.send(
event,
relay.url
)
}
}
@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
import nostr.postr.toHex
import fr.acinq.secp256k1.Hex
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private var searchString: String? = null
@@ -19,15 +19,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
}
val hexToWatch = try {
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
decodePublicKey(mySearchString).toHex()
} else if (mySearchString.startsWith("note")) {
mySearchString.bechToBytes().toHex()
} else {
mySearchString
}
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
null
}
@@ -55,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
)
)
)
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
)
)
}
}
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
}
fun add(eventId: String) {
@@ -26,10 +26,11 @@ 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
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -76,11 +77,14 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -111,16 +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
),
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, 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
)
@@ -81,9 +90,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
limit = 100
)
)
}
@@ -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))
}
@@ -4,7 +4,7 @@ import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
"lnurl.+",
Pattern.CASE_INSENSITIVE
)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
@@ -9,6 +10,7 @@ import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
@Immutable
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import java.io.ByteArrayInputStream
@Immutable
class AppDefinitionEvent(
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 appMetaData() = try {
MetadataEvent.metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
runCatching { it[1].toInt() }.getOrNull()
}
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
companion object {
const val kind = 31990
}
}
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class AppRecommendationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
ATag.parse(it[1], it.getOrNull(2))
}
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 31989
}
}
@@ -0,0 +1,63 @@
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 AudioTrackEvent(
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 participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
companion object {
const val kind = 31337
private const val TYPE = "c"
private const val PRICE = "price"
private const val COVER = "cover"
private const val SUBJECT = "subject"
private const val MEDIA = "media"
fun create(
type: String,
media: String,
price: String? = null,
cover: String? = null,
subject: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): AudioTrackEvent {
val tags = listOfNotNull(
listOf(MEDIA, media),
listOf(TYPE, type),
price?.let { listOf(PRICE, it) },
cover?.let { listOf(COVER, it) },
subject?.let { listOf(SUBJECT, it) }
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Participant(val key: String, val role: String?)
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeAwardEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeDefinitionEvent(
id: HexKey,
pubKey: HexKey,
@@ -9,9 +11,9 @@ class BadgeDefinitionEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : 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 name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeProfilesEvent(
id: HexKey,
pubKey: HexKey,
@@ -9,7 +11,7 @@ class BadgeProfilesEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
@@ -18,8 +20,8 @@ class BadgeProfilesEvent(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 30008
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
@Immutable
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -16,9 +18,10 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = taggedEvents()
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
@@ -61,8 +64,10 @@ open class BaseTextNoteEvent(
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
fun findCitations(): Set<HexKey> {
citedNotesCache?.let { return it }
val citations = mutableSetOf<HexKey>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
@@ -102,6 +107,7 @@ open class BaseTextNoteEvent(
}
}
citedNotesCache = citations
return citations
}
@@ -1,10 +1,12 @@
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 BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
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 ChannelCreateEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
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 ChannelHideMessageEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
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 ChannelMessageEvent(
id: HexKey,
pubKey: HexKey,
@@ -14,13 +16,28 @@ class ChannelMessageEvent(
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(
message: String,
channel: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -35,6 +52,12 @@ class ChannelMessageEvent(
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
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 ChannelMetadataEvent(
id: HexKey,
pubKey: HexKey,
@@ -14,7 +16,7 @@ class ChannelMetadataEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun channelInfo() =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
@@ -1,10 +1,12 @@
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 ChannelMuteUserEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
class ContactListEvent(
@@ -21,14 +23,12 @@ class ContactListEvent(
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
try {
decodePublicKey(it[1]).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}.toSet()
}
@@ -1,10 +1,12 @@
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 DeletionEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
@@ -14,6 +15,7 @@ import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@Immutable
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@@ -46,6 +48,16 @@ open class Event(
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
(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 {
@@ -130,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> {
@@ -204,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())
@@ -220,6 +231,9 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
@@ -235,7 +249,10 @@ 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)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -243,10 +260,12 @@ open class Event(
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
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)
@@ -271,7 +290,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 {
@@ -282,3 +301,9 @@ open class Event(
}
}
}
@Immutable
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
}
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
@Immutable
interface EventInterface {
fun id(): HexKey
@@ -35,4 +37,6 @@ interface EventInterface {
fun getPoWRank(): Int
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
}
@@ -1,10 +1,12 @@
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 FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
@Immutable
class FileStorageEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
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 FileStorageHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
import nostr.postr.Utils
@Immutable
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
@@ -14,9 +16,9 @@ abstract class GeneralListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()
@@ -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())
}
}
}
@@ -1,10 +1,12 @@
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 HighlightEvent(
id: HexKey,
pubKey: HexKey,
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
private fun innerActivity() = tags.firstOrNull {
it.size > 3 && it[0] == "a" && it[3] == "root"
} ?: tags.firstOrNull {
it.size > 1 && it[0] == "a"
}
private fun activityHex() = innerActivity()?.let {
it.getOrNull(1)
}
fun activity() = innerActivity()?.let {
if (it.size > 1) {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
} else {
null
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
companion object {
const val kind = 1311
fun create(
message: String,
activity: ATag,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("a", activity.toTag(), "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -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())
}
}
}
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import java.math.BigDecimal
@Immutable
interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -12,6 +13,7 @@ import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
@Immutable
class LnZapPaymentRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
@Immutable
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
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
@@ -11,6 +13,7 @@ import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@Immutable
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -71,7 +74,7 @@ class LnZapRequestEvent(
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
if (pollOption != null && pollOption >= 0) {
@@ -131,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 {
@@ -1,10 +1,12 @@
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 LongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -12,9 +14,9 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
@@ -1,20 +1,17 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.io.ByteArrayInputStream
import java.util.Date
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?
)
abstract class IdentityClaim(
var identity: String,
var proof: String
@@ -143,9 +140,13 @@ class MetadataEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java)
metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e)
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
@@ -162,8 +163,10 @@ class MetadataEvent(
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.hexToByteArray
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
@@ -15,9 +17,9 @@ class MuteListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? {
return try {
@@ -1,10 +1,12 @@
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 PeopleListEvent(
id: HexKey,
pubKey: HexKey,
@@ -0,0 +1,43 @@
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 PinListEvent(
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 pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
companion object {
const val kind = 33888
fun create(
pins: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PinListEvent {
val tags = mutableListOf<List<String>>()
pins.forEach {
tags.add(listOf("pin", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -1,5 +1,6 @@
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
@@ -11,6 +12,7 @@ const val VALUE_MINIMUM = "value_minimum"
const val CONSENSUS_THRESHOLD = "consensus_threshold"
const val CLOSED_AT = "closed_at"
@Immutable
class PollNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -49,7 +51,9 @@ class PollNoteEvent(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?
zapReceiver: String?,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -73,6 +77,12 @@ class PollNoteEvent(
if (zapReceiver != null) {
tags.add(listOf("zap", zapReceiver))
}
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)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
@@ -8,6 +9,7 @@ import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
@Immutable
class PrivateDmEvent(
id: HexKey,
pubKey: HexKey,
@@ -27,6 +29,10 @@ class PrivateDmEvent(
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
}
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*
@@ -37,7 +43,7 @@ class PrivateDmEvent(
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
@@ -71,7 +77,9 @@ class PrivateDmEvent(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
advertiseNip18: Boolean = true,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -92,6 +100,12 @@ class PrivateDmEvent(
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -1,10 +1,12 @@
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 ReactionEvent(
id: HexKey,
pubKey: HexKey,
@@ -32,7 +34,7 @@ class ReactionEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
@@ -1,11 +1,13 @@
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.net.URI
import java.util.Date
@Immutable
class RecommendRelayEvent(
id: HexKey,
pubKey: HexKey,
@@ -1,10 +1,12 @@
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 RelayAuthEvent(
id: HexKey,
pubKey: HexKey,
@@ -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())
}
}
}
@@ -1,13 +1,16 @@
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
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
@Immutable
class ReportEvent(
id: HexKey,
pubKey: HexKey,
@@ -63,7 +66,7 @@ class ReportEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
if (reportedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
}
@@ -1,11 +1,13 @@
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 RepostEvent(
id: HexKey,
pubKey: HexKey,
@@ -34,9 +36,9 @@ 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 LongTextNoteEvent) {
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -17,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
@@ -27,22 +33,50 @@ class TextNoteEvent(
addresses: List<ATag>?,
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))
tags.add(listOf("t", it.lowercase()))
}
extraTags?.forEach {
tags.add(listOf("t", it))
@@ -53,6 +87,12 @@ class TextNoteEvent(
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
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)
@@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList()
return (
zaps
.filter { it.value != null }
.toList()
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
.mapNotNull { entry ->
entry.value?.let {
ZapReqResponse(entry.key, it)
}
}
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
.reversed()
) as List<Pair<Note, Note>>
)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
@@ -15,6 +16,7 @@ object Nip19 {
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
@Immutable
data class Return(
val type: Type,
val hex: String,
@@ -13,15 +13,24 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
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)
}
}
}
}
@@ -54,6 +63,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
@@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import coil.executeBlocking
@@ -133,13 +134,23 @@ object NotificationUtils {
channelId: String,
applicationContext: Context
) {
val notId = id.hashCode()
// dont notify twice
val notifications: Array<StatusBarNotification> = getActiveNotifications()
for (notification in notifications) {
if (notification.id == notId) {
return
}
}
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
data = Uri.parse(uri)
}
val contentPendingIntent = PendingIntent.getActivity(
applicationContext,
id.hashCode(),
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE
)
@@ -173,7 +184,7 @@ object NotificationUtils {
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(id.hashCode(), builder.build())
notify(notId, builder.build())
}
/**
@@ -0,0 +1,49 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
fetch(timeOut)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(url, document)
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
}
}
return isImage
}
fun cleanUp() {
scope.cancel()
callback = null
}
}
@@ -0,0 +1,6 @@
package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback {
fun onComplete(urlInfo: UrlInfoItem)
fun onFailed(throwable: Throwable)
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service.previews
import androidx.compose.runtime.Immutable
import java.net.URL
@Immutable
data class UrlInfoItem(
val url: String = "",
val title: String = "",
val description: String = "",
val image: String = ""
) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath =
if (image.startsWith("/")) {
URL(verifiedUrl, image).toString()
} else {
image
}
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
}
@@ -0,0 +1,113 @@
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/* for <meta property="og:" to get title */
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
/* for <meta property="og:" to get description */
private val META_OG_DESCRIPTION =
arrayOf("og:description", "\"og:description\"", "'og:description'")
/* for <meta property="og:" to get image */
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
/*for <meta name=... to get title */
private val META_NAME_TITLE = arrayOf(
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'"
)
/*for <meta name=... to get description */
private val META_NAME_DESCRIPTION = arrayOf(
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'"
)
/*for <meta name=... to get image */
private val META_NAME_IMAGE = arrayOf(
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
/*for <meta itemprop=... to get title */
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
/*for <meta itemprop=... to get description */
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
/*for <meta itemprop=... to get image */
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
return@withContext Jsoup.connect(url)
.proxy(HttpClient.getProxy())
.timeout(timeOut)
.get()
}
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image)
}
}
return@withContext UrlInfoItem(url, title, description, image)
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -32,6 +33,8 @@ object Client : RelayPool.Listener {
@Synchronized
fun connect(relays: Array<Relay>) {
checkNotInMainThread()
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
@@ -54,6 +57,8 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
@@ -62,15 +67,19 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
checkNotInMainThread()
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
@@ -91,7 +100,7 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
@@ -103,7 +112,7 @@ object Client : RelayPool.Listener {
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
@@ -30,6 +31,7 @@ class Relay(
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
@@ -68,7 +70,10 @@ class Relay(
@Synchronized
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
checkNotInMainThread()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
@@ -78,6 +83,7 @@ class Relay(
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
checkNotInMainThread()
if (socket != null) return
try {
@@ -88,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
@@ -98,6 +106,8 @@ class Relay(
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
@@ -152,6 +162,8 @@ class Relay(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
@@ -162,6 +174,8 @@ class Relay(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
socket = null
isReady = false
afterEOSE = false
@@ -170,6 +184,8 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
errorCounter++
socket?.close(1000, "Normal close")
@@ -208,6 +224,8 @@ class Relay(
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
@@ -232,6 +250,8 @@ class Relay(
}
fun sendFilterOnlyIfDisconnected() {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
@@ -242,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
@@ -30,6 +31,10 @@ object RelayPool : Relay.Listener {
return relays.firstOrNull() { it.url == url }
}
fun getRelays(url: String): List<Relay> {
return relays.filter { it.url == url }
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }
@@ -44,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)
}
})
}

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