Compare commits

...
498 Commits
Author SHA1 Message Date
Vitor Pamplona 0e1e379c7d v0.88.2 2024-06-20 16:30:33 -04:00
Vitor Pamplona b39ac214a5 Limits the amount of zaps on the user profile 2024-06-20 16:30:12 -04:00
Vitor Pamplona 9e9e0667cd Reverts old migration of accounts due the fact that some people were still in the old model 2024-06-20 16:29:48 -04:00
Vitor Pamplona 82f99faec8 Fixes name instead of uri 2024-06-20 12:22:07 -04:00
Vitor PamplonaandGitHub d07a767fa5 Merge pull request #931 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-20 09:55:47 -04:00
Crowdin Bot ec19038e5c New Crowdin translations by GitHub Action 2024-06-20 13:50:41 +00:00
Vitor Pamplona eaa09055fc v0.88.1 2024-06-20 09:48:10 -04:00
Vitor Pamplona 0d956c2f39 Removes the need to exclude byte-buddy due to jackson's earlier version bug. 2024-06-20 09:47:43 -04:00
Vitor Pamplona 3a4e2e9623 Don't use DM kinds to get information about other logged-in accounts 2024-06-20 09:47:11 -04:00
Vitor Pamplona d33befe1ad Removes account migration and unused keys for shared preferences. 2024-06-20 09:42:10 -04:00
Vitor Pamplona d28f20519b v0.88.0 2024-06-19 16:34:01 -04:00
Vitor Pamplona 239f390e7c Fixes Fdroid tests 2024-06-19 15:43:10 -04:00
Vitor Pamplona 857d16582a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 15:40:23 -04:00
Vitor Pamplona aae47aa2e1 Fixing Options Button 2024-06-19 15:40:14 -04:00
Vitor Pamplona f115111c49 Centralizes stringResource calls to cache them. 2024-06-19 15:12:07 -04:00
Vitor PamplonaandGitHub 83058500d6 Merge pull request #930 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 13:08:48 -04:00
Crowdin Bot 2cb1e70170 New Crowdin translations by GitHub Action 2024-06-19 16:38:15 +00:00
Vitor Pamplona 73513713eb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 12:36:29 -04:00
Vitor Pamplona 7bf5c03637 removing the state needs for rendering text. 2024-06-19 12:36:22 -04:00
Vitor Pamplona d39d1239f6 Moves uncited hashtag parsing to a thread 2024-06-19 12:36:09 -04:00
Vitor Pamplona a0271a2982 Moves ClickableBox to it's own file 2024-06-19 12:35:25 -04:00
Vitor PamplonaandGitHub 2484927c22 Merge pull request #929 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 08:54:42 -04:00
Crowdin Bot bff662b85d New Crowdin translations by GitHub Action 2024-06-19 12:53:07 +00:00
Vitor PamplonaandGitHub f293f6c169 Merge pull request #928 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-19 08:52:07 -04:00
Vitor Pamplona e9456af15c Fixes the inconsistency of button animations in the reaction row 2024-06-19 08:48:41 -04:00
Crowdin Bot 157fad6a84 New Crowdin translations by GitHub Action 2024-06-19 12:48:33 +00:00
Vitor Pamplona 6c137a5174 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-19 08:39:09 -04:00
Vitor Pamplona 6550bdefd5 Thinner Zap button 2024-06-18 19:34:43 -04:00
Vitor Pamplona 614e9ff661 Correctly tints the search button 2024-06-18 19:33:22 -04:00
Vitor Pamplona 5da9e430b0 Get's rid of the view count. 2024-06-18 19:33:04 -04:00
Vitor PamplonaandGitHub fbd32bf426 Merge pull request #927 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-18 19:07:45 -04:00
Vitor Pamplona f28d96d303 removes remember from LoadUrlPreview 2024-06-18 19:00:26 -04:00
Vitor Pamplona 442322f708 Removes the alpha channel for the performance placeholder painter 2024-06-18 19:00:10 -04:00
Vitor Pamplona a463e8889c Adds an outlined Zap icon to match the other icons 2024-06-18 18:59:41 -04:00
Crowdin Bot 224e54cc72 New Crowdin translations by GitHub Action 2024-06-18 22:23:30 +00:00
Vitor Pamplona a23cbd1a38 Formatting 2024-06-18 18:21:27 -04:00
Vitor Pamplona 548dd9e34f Makes sure the isAcceptable runs on IO thread 2024-06-18 18:20:58 -04:00
Vitor Pamplona c27f4a62b6 Switches Social Icons to faster SVG versions 2024-06-18 18:15:02 -04:00
Vitor Pamplona e18945b378 Adds performance mode for older devices. 2024-06-18 09:13:48 -04:00
Vitor Pamplona 08869ac350 removes derived states and some remember functions for performance. 2024-06-18 09:11:32 -04:00
Vitor Pamplona 5d5c3ae3e3 Faster calculation of uncited hashtags. 2024-06-17 17:22:39 -04:00
Vitor Pamplona 0fa6dce42c Fixes giftwrap date mismatch 2024-06-17 16:20:40 -04:00
Vitor Pamplona aad95595eb Quick Refactoring of the Author UI components. 2024-06-17 14:37:31 -04:00
Vitor PamplonaandGitHub 24fede3768 Merge pull request #925 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-16 19:43:00 -04:00
Crowdin Bot a8b6118e2d New Crowdin translations by GitHub Action 2024-06-16 23:37:55 +00:00
Vitor Pamplona 719be8469f Fix test cases 2024-06-16 19:36:41 -04:00
Vitor Pamplona 1496052bba Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-15 12:36:12 -04:00
Vitor Pamplona 545df83177 Simple refactoring 2024-06-15 12:34:03 -04:00
Vitor Pamplona 80d506be37 Allows login with NIP-05 address 2024-06-15 12:33:43 -04:00
Vitor Pamplona ae0ae413e6 Fixes Go Button on new user screen 2024-06-15 12:33:06 -04:00
Vitor PamplonaandGitHub 6a9f9e3f81 Merge pull request #922 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-15 11:36:25 -04:00
Crowdin Bot 010402ebd5 New Crowdin translations by GitHub Action 2024-06-15 15:31:05 +00:00
Vitor Pamplona c07a0065a1 Upgrades Compose, Lifecycle and Fragments 2024-06-15 11:28:20 -04:00
Vitor PamplonaandGitHub 704af55636 Merge pull request #921 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-15 09:48:42 -04:00
Crowdin Bot 475e1c6fe0 New Crowdin translations by GitHub Action 2024-06-15 13:47:01 +00:00
Vitor PamplonaandGitHub 21c397b5e0 Merge pull request #920 from davotoula/915-NIP05-verification-follows-redirects-against-specification
Set redirects to false for httpclient used during nip05
2024-06-15 09:45:49 -04:00
David Kaspar 29a002e451 Set redirects to false for httpclient used during nip05 2024-06-15 09:51:05 +02:00
Vitor PamplonaandGitHub 035affadb9 Merge pull request #919 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 12:32:49 -04:00
Vitor PamplonaandGitHub 79e3142f2d Merge branch 'main' into l10n_crowdin_translations 2024-06-14 12:32:41 -04:00
Crowdin Bot bf880983a7 New Crowdin translations by GitHub Action 2024-06-14 16:30:32 +00:00
Vitor PamplonaandGitHub 115746353e Merge pull request #917 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 12:29:22 -04:00
Vitor PamplonaandGitHub 2fd4521e1d Merge pull request #918 from davotoula/correct_spelling
Correct the spelling of a few translation keywords
2024-06-14 12:29:05 -04:00
Crowdin Bot 3a8a9e296e New Crowdin translations by GitHub Action 2024-06-14 16:01:39 +00:00
Vitor Pamplona 86ebb78823 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-14 11:59:24 -04:00
Vitor PamplonaandGitHub bda0e37767 Merge pull request #916 from greenart7c3/main
Fix hidden notes when hidden words is empty
2024-06-14 11:59:07 -04:00
David Kaspar 900cc03e4f correct spelling 2024-06-14 17:55:41 +02:00
David Kaspar 013357ae6b correct spelling 2024-06-14 17:51:33 +02:00
David Kaspar a58a67d404 correct spelling 2024-06-14 17:50:28 +02:00
Vitor Pamplona 2ded31dd55 Improves the speed of RobohashAsync by avoiding remember and base64 state. 2024-06-14 11:42:27 -04:00
greenart7c3 dad455f9a0 Fix hidden notes when hidden words if empty 2024-06-14 12:35:17 -03:00
Vitor Pamplona 8b87f04268 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-14 09:34:59 -04:00
Vitor Pamplona 627c8d1663 Moves to AGP 8.5 and Gradle 8.7 2024-06-14 09:34:48 -04:00
Vitor PamplonaandGitHub 287a64be11 Merge pull request #914 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-14 08:39:45 -04:00
Crowdin Bot 8f24eeaf4a New Crowdin translations by GitHub Action 2024-06-14 12:33:24 +00:00
Vitor Pamplona 39efc2ef9b Adjusting zap calculations 2024-06-14 08:31:06 -04:00
Vitor Pamplona 52a24d5223 Removing unnecessary modifiers and remember calls 2024-06-14 08:22:38 -04:00
Vitor Pamplona ca8d32b837 Moves chatroom user group away from immutable sets 2024-06-14 08:22:24 -04:00
Vitor Pamplona f508f45e16 Fixes Url Preview Crop when the image is too small or to big for the preview card. 2024-06-14 08:16:51 -04:00
Vitor Pamplona d7a22f609a Small optimizations: removing animations, avoiding remembers 2024-06-13 19:08:20 -04:00
Vitor Pamplona 2503cbaeac - Unifies All Badge rendering
- Constraints badge layouts on Notes
- Creates a flow cache to speed up Note relay layout
- Speeds up NoteQuickActionMenus
- Optimizes NIP-11 pings
- Better error messages when NIP-11 fails
2024-06-13 16:50:19 -04:00
Vitor Pamplona 6bc305dbe6 Refactoring 2024-06-13 16:38:48 -04:00
Vitor Pamplona 26cbc0754e Creates a Loading state to avoid requesting NIP-11 documents twice. 2024-06-13 13:31:51 -04:00
Vitor Pamplona 45445c03e5 - Merges Hidden and Reporting flows
- Removes Report Live data
- Refactors Full Bleed design
- Unifies Hidden and Report checks between the Video Feed, the Full Bleed Design and the Card layout.
2024-06-12 16:54:39 -04:00
Vitor Pamplona 0d00f2f80a No need to remember createdAts 2024-06-12 16:49:10 -04:00
Vitor PamplonaandGitHub 27f437020e Merge pull request #911 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-12 15:10:24 -04:00
Crowdin Bot 2345c45ebf New Crowdin translations by GitHub Action 2024-06-12 19:07:56 +00:00
Vitor Pamplona 42408978c4 - Moves away from persistent collections (slower)
- Adds a hashcode cash for spam and blocked user public keys
- Adds a cache for isHidden
- Moves isHidden composable from LiveData to Flow
2024-06-12 15:05:46 -04:00
Vitor Pamplona a716d13c69 Slightly faster is content sensitive method 2024-06-12 14:56:51 -04:00
Vitor Pamplona 77393b433e formatting 2024-06-12 12:41:31 -04:00
Vitor Pamplona a7c06b46a7 Use navigateUp instead of popBackStack to avoid closing the app on double clicks 2024-06-12 12:40:43 -04:00
Vitor Pamplona 1de55bb16f Migrates to AGP 8.4.2 2024-06-12 08:01:54 -04:00
Vitor Pamplona 20520bbce4 Fixes the centered url on videos without active playback 2024-06-11 19:15:02 -04:00
Vitor Pamplona 42e11d239d Fixes the padding of the reaction row on quotes 2024-06-11 18:51:47 -04:00
Vitor Pamplona 957ad1e0db Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-11 18:20:35 -04:00
Vitor Pamplona 3123685f88 Tries to solve notification dots active for hidden posts. 2024-06-11 18:20:27 -04:00
Vitor Pamplona 31e8782b1a adds a native map for tags in the Event class to avoid creating sublists before mapping tags 2024-06-11 18:19:52 -04:00
Vitor Pamplona cafad6a003 Refactoring 2024-06-11 18:19:07 -04:00
Vitor Pamplona b785e0d505 Removes old Robohash 2024-06-11 18:17:51 -04:00
Vitor PamplonaandGitHub 226153a359 Merge pull request #910 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-11 15:23:06 -04:00
Crowdin Bot b70aa54aa3 New Crowdin translations by GitHub Action 2024-06-11 19:07:46 +00:00
Vitor Pamplona fc96d89a44 Reducing double launch or co-routines 2024-06-11 15:05:57 -04:00
Vitor Pamplona dd71b931fc Reducing memory recreation by only setting the current subscription as after EOSE 2024-06-11 15:05:38 -04:00
Vitor Pamplona d6abdf16e0 Increasing the download limit for reactions to event 2024-06-11 15:05:11 -04:00
Vitor Pamplona 66e18e17c5 Adds relays to zap request:
- sender, receiver, author
2024-06-11 13:32:13 -04:00
Vitor Pamplona 7b2da8312d Adds two extra nevent test cases. 2024-06-11 13:31:45 -04:00
Vitor Pamplona c71c87e946 Better rounding for zaps 2024-06-11 11:30:55 -04:00
Vitor Pamplona 9ca6151313 - Adds the NIP-65 relay to zap split tag instead of kind3 relays.
- Rounds percentages to the 2 decimals.
2024-06-11 11:23:20 -04:00
Vitor Pamplona b322efc286 Fixes padding of the zap raiser 2024-06-11 11:22:21 -04:00
Vitor Pamplona b775c1bf74 Avoids showing error message if devs have removed their lnadress on the Zap the Devs card. 2024-06-11 11:22:09 -04:00
Vitor Pamplona a6b2671b1c Fixes padding of the zap split section on the master note 2024-06-11 11:20:44 -04:00
Vitor Pamplona abc5d0ea2f Fixes border of the zap card 2024-06-11 10:46:15 -04:00
Vitor Pamplona 3c25d91b98 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-11 10:36:52 -04:00
Vitor PamplonaandGitHub 3d8aace55a Merge pull request #909 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-11 10:36:44 -04:00
Vitor Pamplona bd6202f8e1 - Adds support for AVIF images
- Removes gray border in image urls that couldn't be loaded.
2024-06-11 10:25:11 -04:00
Crowdin Bot c1875e1389 New Crowdin translations by GitHub Action 2024-06-11 14:03:00 +00:00
Vitor Pamplona 3a63c1d1ab Fixes alignment of reactions 2024-06-11 10:00:26 -04:00
Vitor Pamplona 609faf245a Reverts save button 2024-06-10 20:01:37 -04:00
Vitor Pamplona bfd28c4b44 Reverts save button 2024-06-10 20:01:11 -04:00
Vitor PamplonaandGitHub 70a8488f12 Merge pull request #907 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 19:59:47 -04:00
Crowdin Bot cda6e4722c New Crowdin translations by GitHub Action 2024-06-10 23:54:07 +00:00
Vitor Pamplona c4ecf85618 Reverting the spotlight on the Save button 2024-06-10 19:51:52 -04:00
Vitor PamplonaandGitHub 6ed1f9d369 Merge pull request #906 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-10 09:59:32 -04:00
Crowdin Bot aea765ec54 New Crowdin translations by GitHub Action 2024-06-10 13:52:45 +00:00
Vitor Pamplona 165c3bb852 Fixes not centered Blank Notes 2024-06-10 09:50:37 -04:00
Vitor Pamplona 2e9683491f Fixes scrollable drawer for all screen sizes 2024-06-10 09:50:24 -04:00
Vitor Pamplona eea8b99622 v0.87.6 2024-06-07 20:16:52 -04:00
Vitor Pamplona 0db6a0d27b Fixes relay drops and difficulty in connecting.
Refactors the use of relay objects to avoid creating full state relay connections only once
2024-06-07 20:13:15 -04:00
Vitor Pamplona 723e575b4b Fixes the LazyColumn for Hashtags on Profile 2024-06-07 19:28:20 -04:00
Vitor Pamplona b182d409b6 Fixes benchmark tests 2024-06-07 18:50:32 -04:00
Vitor Pamplona 043e37030e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-07 13:46:11 -04:00
Vitor Pamplona 8d730040b8 Improved description for public inbox 2024-06-07 13:46:04 -04:00
Vitor PamplonaandGitHub 794a300c74 Merge pull request #904 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-07 12:42:58 -04:00
Crowdin Bot 91fd490ab7 New Crowdin translations by GitHub Action 2024-06-07 15:54:44 +00:00
Vitor Pamplona 0e1de03db8 Only updates video position when the video is ready to play 2024-06-07 11:52:39 -04:00
Vitor Pamplona 7906a94ca0 Moves the video release to a thread without damaging ON_PAUSE, ON_RELEASE order. 2024-06-07 11:42:15 -04:00
Vitor Pamplona 6ce019a86a Larger search limits for profiles 2024-06-07 10:37:13 -04:00
Vitor Pamplona d8939779e8 Improves UI buttons of the Image Dialog 2024-06-07 10:36:56 -04:00
Vitor Pamplona 313d87a58b Removes debug marker 2024-06-06 18:43:11 -04:00
Vitor Pamplona 057e61bfe1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-06 18:39:48 -04:00
Vitor Pamplona c94c882c3a restructures video and image views to avoid buttons getting on top of one another. 2024-06-06 18:39:39 -04:00
Vitor Pamplona 673f632563 Refactoring 2024-06-06 12:43:59 -04:00
Vitor Pamplona 1a56b2bf68 Log messages when saving to disk. 2024-06-05 18:21:20 -04:00
Vitor PamplonaandGitHub 2e9964e0e8 Merge pull request #903 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-05 16:37:28 -04:00
Crowdin Bot b80eb32991 New Crowdin translations by GitHub Action 2024-06-05 20:30:27 +00:00
Vitor Pamplona ad281c7607 BugFix crashing with old keyboards: Forces transformations to have middle positions before the last cursor position. 2024-06-05 16:28:44 -04:00
Vitor Pamplona 3c1a918b2d Moving attestations local cache to a flow 2024-06-05 13:24:31 -04:00
Vitor Pamplona 53e6677558 Adds Hashtag search and display to Reposted Notes. 2024-06-05 11:58:02 -04:00
Vitor Pamplona b8deb34544 Avoid using DM relays to get chats 2024-06-05 09:34:03 -04:00
Vitor Pamplona d9cffa87ef When broadcasting privately, avoids keeping the connection open for other activities. 2024-06-05 08:33:11 -04:00
Vitor Pamplona ae418f9d45 v0.87.5 2024-06-04 18:46:45 -04:00
Vitor Pamplona d10b4c6bde - Re-normalizes all relays urls before connecting to reduce duplications
- Normalizes all new relays in the edit screens.
2024-06-04 18:30:01 -04:00
Vitor Pamplona f1e516662c Adds flare.pub videos to the media tab on Amethyst 2024-06-04 17:12:14 -04:00
Vitor Pamplona 1bf8165641 Improving the detection of image types on the Media Feed. 2024-06-04 16:38:31 -04:00
Vitor Pamplona 807af3b613 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-04 16:01:50 -04:00
Vitor Pamplona e36ec0a0a3 removes extra char on xml 2024-06-04 16:01:37 -04:00
Vitor PamplonaandGitHub 3172b69271 Merge pull request #902 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-04 15:55:27 -04:00
Crowdin Bot 81ee13bebc New Crowdin translations by GitHub Action 2024-06-04 19:47:45 +00:00
Vitor Pamplona a6a8138c7f - Performance Improvements: Simplifies intrinsic size calculations for Image Previews and Videos
- Improves rendering of chat messages
- Fixes the jittering from resizing Videos and Images during loading.
- Fixes video cut off when loading
- Fixes blurhash covering videos in landscape
- Fixes landscape video centralization
- Fixes chat using Two Pane layout for landscape and regular for portrait
2024-06-04 15:45:59 -04:00
Vitor Pamplona 16142f61b8 normalization refactoring 2024-06-04 10:54:06 -04:00
Vitor Pamplona 7193228dd2 Fixes size of the incognitoIcon on chat messages 2024-06-03 19:32:46 -04:00
Vitor Pamplona 29b8f6916b v0.87.4 2024-06-03 17:29:43 -04:00
Vitor Pamplona a6519d57c6 Passes the video note uri so that when the user clicks in the video popup the app brings it back to the playing screen without breaking the video. 2024-06-03 17:26:40 -04:00
Vitor Pamplona 2b2ee5bbfc Avoids using Video Cache for live streams. 2024-06-03 17:18:38 -04:00
Vitor Pamplona 62a6b7b7e0 Fixes video stream position for live streams. 2024-06-03 17:18:17 -04:00
Vitor Pamplona 5e0a1f9de1 Moves SimpleCache creation to the IO thread. 2024-06-03 17:17:26 -04:00
Vitor PamplonaandGitHub 571b1bc9cf Merge pull request #900 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 14:27:47 -04:00
Crowdin Bot a3e76225e6 New Crowdin translations by GitHub Action 2024-06-03 18:26:14 +00:00
Vitor Pamplona 4388ceb529 v0.87.3 2024-06-03 14:24:28 -04:00
Vitor Pamplona 5d4fa0d732 Adds the incoming relays to the request. 2024-06-03 14:15:12 -04:00
Vitor Pamplona ed6825de97 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 14:04:46 -04:00
Vitor Pamplona 10f623b6b3 Forces URLs to be single line. 2024-06-03 14:04:38 -04:00
Vitor Pamplona 467e5c0e4a Fixes bug that every socket is null 2024-06-03 14:04:29 -04:00
Vitor Pamplona 09d2e9593e Adds error messages to relay information dialog 2024-06-03 14:03:58 -04:00
Vitor PamplonaandGitHub cd20c0567b Merge pull request #899 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 13:27:05 -04:00
Vitor Pamplona db15f5e2a6 Uses the user to avoid Java removing the variable due to lack of usage. 2024-06-03 13:25:56 -04:00
Crowdin Bot 18d5c7e3f4 New Crowdin translations by GitHub Action 2024-06-03 16:08:05 +00:00
Vitor Pamplona d42ae7c9f7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 12:06:38 -04:00
Vitor PamplonaandGitHub 2b741b62e8 Merge pull request #898 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 12:06:14 -04:00
Vitor Pamplona 4e1c63d36a allows more replies to filter when using DVMs 2024-06-03 12:05:58 -04:00
Vitor Pamplona 95797171d7 Fixes observer of authors 2024-06-03 12:05:46 -04:00
Crowdin Bot 395ef9695e New Crowdin translations by GitHub Action 2024-06-03 15:49:13 +00:00
Vitor Pamplona ad1c7088c7 Loads the DVM's NIP65 records. 2024-06-03 11:47:39 -04:00
Vitor Pamplona eda4569b64 Only shows debug relay writes in DEBUG 2024-06-03 11:47:20 -04:00
Vitor Pamplona fb5f1e738b Adds a user and max result params to the DVM request. 2024-06-03 11:47:00 -04:00
Vitor Pamplona e3cca6c691 Pulls NIP-65 relays even in the first lookup for the user. 2024-06-03 11:45:57 -04:00
Vitor Pamplona ce2b8dfa11 Sends the DVM request to the NIP65 Inbox relays. 2024-06-03 11:15:36 -04:00
Vitor Pamplona b5faf6ffc5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-03 11:15:07 -04:00
Vitor PamplonaandGitHub 4ee157f27f Merge pull request #897 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 11:14:50 -04:00
Vitor Pamplona ad8c04b79a Loads user's relays on scrolling. 2024-06-03 11:14:33 -04:00
Crowdin Bot c0e6273954 New Crowdin translations by GitHub Action 2024-06-03 15:04:13 +00:00
Vitor Pamplona aebade21a6 Don't display zap splits when the post is short. 2024-06-03 11:02:23 -04:00
Vitor Pamplona bda24c0101 Separates the Account's relay info to the UI component 2024-06-03 11:01:34 -04:00
Vitor Pamplona 3d0b461550 Moves Relay Stats to it's own cache.
Refactors the isLocalRelay function
Removes unnecessary onError methods from the Relay class
Moves byte counts from Int to Long
2024-06-03 10:42:00 -04:00
Vitor PamplonaandGitHub 5900e6c49f Merge pull request #896 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-03 08:45:33 -04:00
Crowdin Bot f29fe3ced5 New Crowdin translations by GitHub Action 2024-06-03 12:38:46 +00:00
Vitor Pamplona bf17cc8f03 Updating Firebase and Androidx JUnit 2024-06-03 08:36:45 -04:00
Vitor Pamplona a8d10c22d4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-06-01 16:14:47 -04:00
Vitor PamplonaandGitHub 5492ebf43f Merge pull request #893 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 16:13:05 -04:00
Vitor Pamplona 0c48366d56 v0.80.2 2024-06-01 16:12:49 -04:00
Crowdin Bot d66a027270 New Crowdin translations by GitHub Action 2024-06-01 19:33:36 +00:00
Vitor Pamplona f1834af506 Moves the filter for no subscriptions to the DAL 2024-06-01 15:31:43 -04:00
Vitor Pamplona f26fa71e49 improves the speed of the kind filter for DVMs 2024-06-01 15:20:26 -04:00
Vitor Pamplona 4faee68174 Filters by supported kinds in the DVM view. 2024-06-01 15:16:35 -04:00
Vitor Pamplona 2326e66382 Solves race conditions when connecting to relays. 2024-06-01 15:16:18 -04:00
Vitor PamplonaandGitHub bf71365f51 Merge pull request #892 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 13:18:30 -04:00
Crowdin Bot f45e8d491c New Crowdin translations by GitHub Action 2024-06-01 17:17:09 +00:00
Vitor Pamplona 5a476cab32 v0.87.0 2024-06-01 13:15:27 -04:00
Vitor Pamplona b5b0a19df5 Fixes the disappearance of the notification icon 2024-06-01 13:14:30 -04:00
Vitor PamplonaandGitHub 85d26dee08 Merge pull request #891 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-06-01 10:58:27 -04:00
Crowdin Bot 476da07d3d New Crowdin translations by GitHub Action 2024-06-01 14:55:45 +00:00
Vitor Pamplona 3b7344e894 v0.87.0 2024-06-01 10:53:39 -04:00
Vitor Pamplona d307bb0e49 Fixes missing relays certain internal lists 2024-05-31 19:47:40 -04:00
Vitor Pamplona 1bbcb97d54 Fixes read status between others and the logged in user 2024-05-31 16:54:18 -04:00
Vitor Pamplona 3a9637ccb9 Optimizes Blurhash generation 2024-05-31 14:24:55 -04:00
Vitor Pamplona 071da53a6a Waits 500ms before restarting all connections. 2024-05-30 17:11:11 -04:00
Vitor Pamplona 9d190f93bb deactivate write for search relays (they are read only) 2024-05-30 17:10:56 -04:00
Vitor Pamplona 7c65a93896 - Only updates lists if they were changed.
- Refactoring RelayLists
2024-05-30 17:10:38 -04:00
Vitor Pamplona d9b96d2fd5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 16:20:00 -04:00
Vitor Pamplona 3333fdc237 Layout improvements for the relay screen 2024-05-30 16:19:52 -04:00
Vitor Pamplona e178ff988d Adds NIP-50 requirements to the Search explainer. 2024-05-30 16:19:39 -04:00
Vitor PamplonaandGitHub df649445d0 Merge pull request #890 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 16:02:37 -04:00
Vitor Pamplona a38346c7d3 Adds private outbox relays to save Draft events. 2024-05-30 16:02:21 -04:00
Crowdin Bot 71da587426 New Crowdin translations by GitHub Action 2024-05-30 19:58:38 +00:00
Vitor PamplonaandGitHub 6ecb5ecc47 Merge pull request #889 from believethehype/main
show error messages if dvm status returns error
2024-05-30 15:57:23 -04:00
Believethehype b2a19efcd0 show error messages, if dvm status returns error 2024-05-30 20:12:06 +02:00
Vitor Pamplona 1123586ef6 Uses DM Relay List to activate nip17 messages in chats. 2024-05-30 14:00:20 -04:00
Vitor Pamplona ab037e61aa adds local relays to the connecting set 2024-05-30 13:51:31 -04:00
Vitor Pamplona 4a43b57f79 Uses NIP-65 preferably for zap requests. 2024-05-30 13:51:14 -04:00
Vitor Pamplona f81c83326c Relay screen padding adjustments 2024-05-30 13:33:11 -04:00
Vitor Pamplona fdd1dd1855 Adds a title to the relay settings screen 2024-05-30 12:44:29 -04:00
Vitor Pamplona 9f3d4db9dd Adds Local Relays to the relay list screen, saving locally in the device. 2024-05-30 12:35:24 -04:00
Vitor Pamplona c23a9f8ad6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 11:21:47 -04:00
Vitor Pamplona 36b0c8acb5 Fixing crash when calling localcache on the main thread. 2024-05-30 11:21:39 -04:00
Vitor PamplonaandGitHub dc12789166 Merge pull request #888 from KotlinGeekDev/unifiedpush-updates
UnifiedPush library updates
2024-05-30 11:20:38 -04:00
KotlinGeekDevandGitHub 942b0fa531 Merge branch 'vitorpamplona:main' into unifiedpush-updates 2024-05-30 15:07:48 +00:00
KotlinGeekDev 8ae7f31c3d Refactor getSavedDistributor(). It uses UnifiedPush.getSavedDistributor underneath to preserve pre-API change behaviour. 2024-05-30 16:04:30 +01:00
KotlinGeekDev bc1bbc2f70 Update to up-connector to v2.3.1. The JVM toolchain and Kotlin downgrade in 2.4.0 may not be good for Amethyst now. Will upgrade when everything has checked out. 2024-05-30 16:01:22 +01:00
Vitor Pamplona 632c78792a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-30 09:55:00 -04:00
Vitor Pamplona e2a719164f Migrates Kotlin and dependencies to the latest version 2024-05-30 09:48:45 -04:00
Vitor Pamplona ff94f45d04 Enabling download and creation of NIP-96 file servers. 2024-05-30 09:38:33 -04:00
Vitor Pamplona 7e1b1c7d2c No need for experimental tag anymore. 2024-05-30 09:37:15 -04:00
Vitor PamplonaandGitHub f6f9d82665 Merge pull request #887 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:41:51 -04:00
Crowdin Bot 3638059c23 New Crowdin translations by GitHub Action 2024-05-30 12:41:41 +00:00
Vitor Pamplona a781e733f2 improving the filter for the request id of the cached response. 2024-05-30 08:39:23 -04:00
Vitor PamplonaandGitHub 56d6376efc Merge pull request #886 from believethehype/main
Check if NIP90 Result exists before relying on Cache
2024-05-30 08:34:59 -04:00
Vitor PamplonaandGitHub 3ac9d3a4f7 Merge pull request #885 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 08:33:49 -04:00
Believethehype 521da9a774 check if NIP90 Result list exists before relying on Cache 2024-05-30 14:23:24 +02:00
Crowdin Bot 18ebdaf96d New Crowdin translations by GitHub Action 2024-05-30 10:50:40 +00:00
Vitor PamplonaandGitHub 19d394a383 Merge pull request #884 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:49:20 -04:00
Crowdin Bot b198b4c081 New Crowdin translations by GitHub Action 2024-05-30 10:47:55 +00:00
Vitor PamplonaandGitHub 28bc46f120 Merge pull request #883 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-30 06:46:21 -04:00
Crowdin Bot 7a6de4ea49 New Crowdin translations by GitHub Action 2024-05-30 10:33:52 +00:00
Vitor PamplonaandGitHub bc108875e6 Merge pull request #882 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-30 06:32:33 -04:00
David Kaspar aa38b41c58 Update SE, CZ, DE, PT translations 2024-05-30 10:01:33 +02:00
Vitor Pamplona a4c26b1fd8 Reducing the use of backgrounds to improve rendering speeds. 2024-05-29 18:53:26 -04:00
Vitor Pamplona 550cfe8bb4 Fixes relay list assembling 2024-05-29 15:58:47 -04:00
Vitor Pamplona 56664afa4e Show missing relays for DMs when the DMRelayList event is empty. 2024-05-29 15:22:19 -04:00
Vitor Pamplona 90c9deecd4 Removes the forced search relay selection and helps users setup their relay list for search. 2024-05-29 15:21:54 -04:00
Vitor Pamplona add65b094d updates junit 2024-05-29 14:28:07 -04:00
Vitor Pamplona c7a4161799 Fixing tests 2024-05-29 12:50:46 -04:00
Vitor Pamplona f6a16e4394 Fixes date format's year 2024-05-29 12:28:22 -04:00
Vitor Pamplona 44a615020d Improves wording of the relay list 2024-05-29 12:28:10 -04:00
Vitor Pamplona 288aafc3a2 Adds support for search relay lists 2024-05-29 12:06:45 -04:00
Vitor Pamplona aa97c7ee9b Adds NIP-65 relay settings 2024-05-29 10:39:08 -04:00
Vitor Pamplona 51a6eb4974 - Adds a new observer by kind and author
- Uses NIP-17 relays for DMs
- Changes default connection to account from NIP17 relays
- Only sends wraps to the appropriate relays.
2024-05-28 19:13:03 -04:00
Vitor Pamplona f8dfc06a06 Saves DMs and All Relays on the relay list 2024-05-28 19:11:23 -04:00
Vitor Pamplona 2d4db9fea8 Refactoring on latestbykindandetag 2024-05-28 19:09:53 -04:00
Vitor Pamplona 34b743146e Making sure the relay list has different keys for different sections. 2024-05-28 18:48:41 -04:00
Vitor Pamplona 13eb8b1abc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-28 11:49:20 -04:00
Vitor Pamplona e98a77b37f Adds DM relays to the relay list. 2024-05-28 11:49:13 -04:00
Vitor Pamplona 6a17a8a871 Refactors GiftWrap caching to delete encrypted text and reload the wrap if necessary.
- Changes host to a host stub to reduce memory use
- Only download GiftWraps form 2 days past the last EOSE
2024-05-28 11:48:20 -04:00
Vitor PamplonaandGitHub 3ce726bbb7 Merge pull request #881 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-28 08:27:01 -04:00
Crowdin Bot 593bf24f5b New Crowdin translations by GitHub Action 2024-05-28 12:22:25 +00:00
Vitor PamplonaandGitHub 7a857711eb Merge pull request #880 from davotoula/update_tr
Update SE, CZ, DE, PT translations
2024-05-28 08:20:52 -04:00
David Kaspar 420e325d1f Update SE, CZ, DE, PT translations 2024-05-28 09:04:49 +02:00
Vitor PamplonaandGitHub 610657a9b6 Merge pull request #877 from greenart7c3/main
add signString method
2024-05-27 08:15:34 -04:00
greenart7c3 ea891367e9 add signString method 2024-05-27 07:56:02 -03:00
Vitor Pamplona 0c22e66e8f Overrides pubkey to avoid impersonators on seals. 2024-05-24 19:27:14 -04:00
Vitor Pamplona cd008d78a0 Improves the debug of the memory use per event kind 2024-05-24 19:26:39 -04:00
Vitor Pamplona 2c2e4a1694 Clears flows 2024-05-24 19:26:13 -04:00
Vitor Pamplona d7790cd31e Moves the thread formatter and calculator out of Note 2024-05-24 18:11:57 -04:00
Vitor Pamplona 0bb571b52e Fixes position of the edit field after the relay list 2024-05-24 17:42:42 -04:00
Vitor PamplonaandGitHub c65a4b8fe7 Merge pull request #876 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 14:59:55 -04:00
Crowdin Bot abb0c73c71 New Crowdin translations by GitHub Action 2024-05-24 18:56:33 +00:00
Vitor Pamplona 224bcf97ec Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-24 14:54:48 -04:00
Vitor Pamplona 04c449072a Adds support for NIP-06 seed word key derivation (bip32 and bip39) 2024-05-24 14:54:38 -04:00
Vitor PamplonaandGitHub b26aba92a1 Merge pull request #875 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-24 09:12:25 -04:00
Crowdin Bot 5ce9df9dce New Crowdin translations by GitHub Action 2024-05-24 12:20:21 +00:00
Vitor Pamplona 7426600dc6 deleting orangepill since it might not come back 2024-05-24 08:18:32 -04:00
Vitor Pamplona 9d4a88d26f Clear up some old unstable api tags 2024-05-23 18:18:46 -04:00
Vitor Pamplona 85aaf25561 Remove unnecessary logs 2024-05-23 17:11:18 -04:00
Vitor Pamplona 1cd5845960 Fixes the rendering of replies on wikipages. 2024-05-23 17:09:16 -04:00
Vitor Pamplona e38f6be20c Refactoring the composables for the relay list screen 2024-05-23 16:40:24 -04:00
Vitor Pamplona c78c00acd9 Adds method to create the NIP-65 list 2024-05-22 18:23:52 -04:00
Vitor Pamplona 11fa762028 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-22 17:46:45 -04:00
Vitor Pamplona 599fddb369 Adds a card to setup the DM relay list if the user doesn't have one. 2024-05-22 17:46:39 -04:00
Vitor Pamplona 76103ac057 Fixes the padding of the x button on the relay list screen 2024-05-22 17:45:55 -04:00
Vitor Pamplona 6538cbd9da Fixes padding of the chat list elements. 2024-05-22 17:45:18 -04:00
Vitor Pamplona 566ca0e390 Moves Relay URL formatter to Quartz 2024-05-22 17:44:18 -04:00
Vitor PamplonaandGitHub c5f7ea801d Update README.md
Adding the most recent feature sets
2024-05-22 14:54:21 -04:00
Vitor Pamplona 469e7abd99 Clears imeta tags that might duplicate for the same post. 2024-05-22 13:43:07 -04:00
Vitor Pamplona e540954125 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-21 15:56:58 -04:00
Vitor Pamplona f73052ed48 Checks cache before making a new request to DVM 2024-05-21 15:56:51 -04:00
Vitor Pamplona a9437d9001 Refactors CreatedAt Comparator 2024-05-21 15:56:33 -04:00
Vitor PamplonaandGitHub 02f6422bab Merge pull request #871 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-21 14:53:09 -04:00
Crowdin Bot da2b59af49 New Crowdin translations by GitHub Action 2024-05-21 18:52:37 +00:00
Vitor Pamplona 7acdf56e68 Slightly improving the threading for badges. 2024-05-21 14:50:36 -04:00
Vitor Pamplona 24be6cd90d No need to launch, just use IO 2024-05-21 12:26:39 -04:00
Vitor PamplonaandGitHub afee0ddc53 Merge pull request #869 from believethehype/NIP90-ContentDiscovery
update paid dvms with processing status after payment
2024-05-21 08:41:38 -04:00
Believethehype c85b8a1d83 update paid dvms with processing status after payment 2024-05-21 12:11:44 +02:00
Vitor Pamplona cfeedfa4e2 Fixes the use of cache for video files. 2024-05-20 17:42:21 -04:00
Vitor Pamplona dd112d28ae updating some libraries (others are not ready for the app) 2024-05-20 17:11:03 -04:00
Vitor Pamplona fc27526113 Removes reposts from the Dot Notification in the home's bottom bar icon 2024-05-20 16:45:08 -04:00
Vitor Pamplona 666635811b Migrating to AGP 8.4.1 2024-05-20 16:42:11 -04:00
Vitor Pamplona 4e43938f96 Can zap DVMs directly on the regular zap button. 2024-05-20 16:22:27 -04:00
Vitor PamplonaandGitHub 2a744205f0 Merge pull request #868 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-20 15:20:33 -04:00
Crowdin Bot 7e6ca34d2a New Crowdin translations by GitHub Action 2024-05-20 19:16:35 +00:00
Vitor Pamplona 24f7991116 Fixes a white space after a new line 2024-05-20 15:15:06 -04:00
Vitor Pamplona beb901120e Fixes call from main thread 2024-05-20 13:25:53 -04:00
Vitor Pamplona 0936df9851 Fixes sat division and the state changing text. 2024-05-20 13:19:04 -04:00
Vitor Pamplona 9ceb8866ed Refactoring the DVM payment code. 2024-05-20 12:37:55 -04:00
Vitor PamplonaandGitHub c88b21b547 Merge pull request #867 from believethehype/NIP90-ContentDiscovery
Nip90 content discovery - Consider status and amount tags
2024-05-20 11:38:13 -04:00
Vitor PamplonaandGitHub 5c366d5cfc Merge branch 'main' into NIP90-ContentDiscovery 2024-05-20 11:37:07 -04:00
Vitor Pamplona ea70d44ac7 liniting 2024-05-20 11:24:03 -04:00
Believethehype 67f10920f6 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:35:39 +02:00
Believethehype 654632a585 Update NIP90ContentDiscoveryScreen.kt 2024-05-20 00:32:26 +02:00
Believethehype 794b05106b add possibility to zap/pay invoices for dvms 2024-05-20 00:15:30 +02:00
Vitor PamplonaandGitHub 5812e290c9 Merge pull request #866 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-17 18:32:35 -04:00
Crowdin Bot c279c04858 New Crowdin translations by GitHub Action 2024-05-17 22:32:08 +00:00
Vitor Pamplona 01016525d3 Fixes missing parameter 2024-05-17 18:30:48 -04:00
Vitor Pamplona 72c6e93524 Migrates DVM route from pubkey to AppDefinition eventId and makes sure NFC reading will load the AppDefinition itself before rendering the rest of the screen. 2024-05-17 18:06:20 -04:00
Vitor Pamplona d6988ad4e1 fixes redirection of routeFor when passing an App Definition Event. 2024-05-17 18:05:38 -04:00
Vitor Pamplona 4c1cd1c9ab adds DVM picture and name to the top bar and the status feed. 2024-05-17 17:18:31 -04:00
Vitor Pamplona 9fb8d4821e Refactoring the DVM codebase
Allows pull to refresh to request the job again.
2024-05-17 17:02:04 -04:00
Vitor Pamplona 8b052567c4 Adds a new observable system to local cache. 2024-05-17 17:01:57 -04:00
Vitor Pamplona 4a6ea550d6 Fixes the spacing in the Like button 2024-05-17 17:00:19 -04:00
Vitor Pamplona 5c88e7993f Fixes some of the missing classes due to R8 2024-05-16 15:14:51 -04:00
Vitor PamplonaandGitHub 1bd1493bf4 Merge pull request #864 from greenart7c3/main
add paste from clipboard button to nwc
2024-05-16 13:47:19 -04:00
greenart7c3 404278a4e3 add paste from clipboard button to nwc 2024-05-16 14:13:40 -03:00
Vitor Pamplona edca55b0b6 must not use crypto tools on the benchmark 2024-05-16 13:10:38 -04:00
Vitor Pamplona 738187d32b Making generateID public for testing 2024-05-16 13:05:15 -04:00
Vitor Pamplona d9de0d2798 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-16 13:04:17 -04:00
Vitor PamplonaandGitHub b20515b1a0 Merge pull request #862 from believethehype/NIP90-ContentDiscovery
NIP90: cleanup, potential fix endless loop
2024-05-16 13:04:02 -04:00
Vitor Pamplona e6d8291f07 Trying to unify NIP01 Serialization with SHA-256 procedures to reduce creation of several bytearray at every verification. 2024-05-16 13:03:30 -04:00
Vitor Pamplona 18d08bf6e0 Don't obfuscate data classes. 2024-05-16 13:01:15 -04:00
Believethehype b2193f48d5 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 16:48:19 +02:00
Vitor PamplonaandGitHub de391f03b1 Merge pull request #858 from VASHvic/auto_focus_search_screen
auto focus when entering the search screen
2024-05-16 09:29:21 -04:00
Believethehype c494cf8ac1 Update NIP90ContentDiscoveryScreen.kt 2024-05-16 15:20:04 +02:00
Believethehype 193e9a5adb add more info on current dvm state 2024-05-16 15:19:19 +02:00
Believethehype 4fb9c93cf0 Merge branch 'NIP90-ContentDiscovery' of https://github.com/believethehype/amethyst into NIP90-ContentDiscovery 2024-05-16 15:16:33 +02:00
Believethehype 6bd98201f8 add DVM not replying message 2024-05-16 15:16:30 +02:00
believethehypeandGitHub cbc6697631 Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:08:42 +02:00
Vitor PamplonaandGitHub d48634ac0e Merge pull request #861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-16 09:03:48 -04:00
believethehypeandGitHub 5e2c8de15e Merge branch 'main' into NIP90-ContentDiscovery 2024-05-16 15:03:34 +02:00
Believethehype 926a721c53 cleanup, potential fix endless loop 2024-05-16 15:02:44 +02:00
Crowdin Bot 5bc6da3bfc New Crowdin translations by GitHub Action 2024-05-16 13:00:51 +00:00
Vitor PamplonaandGitHub eae066b003 Merge pull request #860 from believethehype/NIP90-ContentDiscovery
Fix order of events, use proper event content parsing
2024-05-16 08:58:54 -04:00
Believethehype b74fa975ba Show DVM Status
Limitation: This will only show the first reply from the DVM, if mutliple status updates follow they will be ignored.
2024-05-16 14:24:13 +02:00
Believethehype 7f2b8519f3 improve parsing 2024-05-16 14:07:11 +02:00
Believethehype 51335e06f1 Fix order of Events in DVM feeds 2024-05-16 13:52:22 +02:00
VASH 0b6cd08c4a auto focus when entering the search screen 2024-05-15 21:59:21 +02:00
Vitor Pamplona 1bfe57da63 Fixes benchmark test for the new isDraft parameter 2024-05-15 10:28:01 -04:00
Vitor PamplonaandGitHub 283d52ac2f Merge pull request #856 from believethehype/NIP90-ContentDiscovery
Nip90 Content Discovery
2024-05-15 10:17:10 -04:00
Vitor Pamplona d1646761d2 Starts the process of adding a DM Relay list card to setup relays for GiftWraps 2024-05-15 10:00:54 -04:00
Believethehype dc8209c90d Update NIP90ContentDiscoveryScreen.kt 2024-05-15 15:13:16 +02:00
Believethehype 420323bcb0 adjustments and cleanups 2024-05-15 15:11:36 +02:00
Believethehype 142aca40ce add NIP90 status events, update NIP90 feeds based on request id 2024-05-15 12:06:12 +02:00
Vitor Pamplona 4fa3d60638 Speeding up the url finder in the new post edit text. 2024-05-14 20:32:48 -04:00
Vitor Pamplona a9eeb04014 Fixes alignment of the like icons after the like event. 2024-05-14 19:26:47 -04:00
Believethehype f09b00ff01 adding 5301 events to be handled 2024-05-15 00:14:15 +02:00
Believethehype ceaae398c2 Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 23:16:49 +02:00
Believethehype 39f87af072 remove notifications for NIP90 Content events 2024-05-14 23:09:43 +02:00
Vitor Pamplona a34a3cbc83 Fixes GirtWrap test 2024-05-14 16:34:43 -04:00
Vitor Pamplona f8afb4b783 Refactor the relay list for chat message event kind 2024-05-14 16:34:32 -04:00
Vitor Pamplona fad4248539 Moves Zap the Devs card to a better position 2024-05-14 16:33:55 -04:00
Believethehype 13a53876d0 fix sorting 2024-05-14 22:21:03 +02:00
Believethehype ea8affaebf general (unpolished) workflow works 2024-05-14 22:01:03 +02:00
Believethehype df44e172ab Update NIP90ContentDiscoveryFilter.kt 2024-05-14 21:48:59 +02:00
Believethehype 8a50b3938d send request, get results (still needs parsing) 2024-05-14 21:41:36 +02:00
Believethehype 0245c907ff add nip90 events to factory 2024-05-14 18:51:38 +02:00
Vitor Pamplona 3bdc75b1be Improves the rendering of Channels and Communities when quoted 2024-05-14 12:40:53 -04:00
Vitor Pamplona 9d02361d01 Fixes wrong display of original and forked notes 2024-05-14 12:40:29 -04:00
Vitor Pamplona 6e6a13c5bf Moves video compressing quality to medium. 2024-05-14 12:12:48 -04:00
Vitor Pamplona 1f45a63081 Makes NIP-17 the default messaging mode
Migrates code from old NIP-24 to NIP-17
2024-05-14 11:34:36 -04:00
Believethehype 11a5f4a67e Merge remote-tracking branch 'upstream/main' into NIP90-ContentDiscovery 2024-05-14 17:06:00 +02:00
Believethehype 52e79580bf attempt to add own filters and event kinds for NIP90 content related events 2024-05-14 17:03:13 +02:00
Vitor Pamplona 561b19c447 Better rendering drafts in the thread 2024-05-14 10:16:41 -04:00
believethehypeandGitHub 9b21c3c964 Merge branch 'vitorpamplona:main' into NIP90-ContentDiscovery 2024-05-14 06:29:19 +02:00
Believethehype baaa984d0d add nip90 content discovery request 2024-05-13 22:43:38 +02:00
Believethehype 2b7ef79d21 send dvm id to render list 2024-05-13 21:57:33 +02:00
Vitor Pamplona 22c96d2489 Use the default comparator instead of creating a new object every time 2024-05-13 15:40:24 -04:00
Vitor Pamplona 0c1187e4f5 Reducing the creation of new buttons when modifier objects change 2024-05-13 15:39:29 -04:00
Vitor Pamplona 7310ef175f DecimalFormats are not thread safe 2024-05-13 15:39:06 -04:00
Vitor Pamplona 53ba65cac2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 13:27:09 -04:00
Vitor Pamplona 58ed27dc75 Adds recommended amounts for the Zap the Devs
Block error messages from closing the Zap split payment screen.
2024-05-13 13:27:03 -04:00
Vitor Pamplona 065ba1c165 Better format zap amounts (don't show .0 if the previous numbers are large) 2024-05-13 13:25:50 -04:00
Believethehype 896d227fea Update DiscoverScreen.kt 2024-05-13 19:25:43 +02:00
Vitor Pamplona 6bac18c5df Fixes the offset position of the payment amounts on the Zap the Devs message 2024-05-13 13:24:53 -04:00
Vitor Pamplona 9ad62ef263 Reduces default zap amounts due to the change of bitcoin price 2024-05-13 13:22:58 -04:00
Believethehype fe45e188bd hide subscription dvms for now 2024-05-13 18:17:36 +02:00
Believethehype 334b948900 app meta data, show content dvms 2024-05-13 17:27:34 +02:00
Vitor PamplonaandGitHub fb9ad2b457 Merge pull request #854 from jeremyd/main
allow relay selection dialog to pick any relays
2024-05-13 10:47:17 -04:00
Vitor PamplonaandGitHub 921bb41596 Merge pull request #855 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-13 10:47:08 -04:00
Crowdin Bot ab4d01583b New Crowdin translations by GitHub Action 2024-05-13 14:44:00 +00:00
Vitor Pamplona 314531e938 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-13 10:42:05 -04:00
Vitor PamplonaandGitHub 991eed9bdf Merge pull request #845 from davotoula/843-URL-previews-mishandle-HTML-entities
843 url previews mishandle html entities
2024-05-13 10:40:33 -04:00
Believethehype 357981f266 add nip89 filter 2024-05-13 16:12:19 +02:00
Believethehype 119e9b7281 Structure for Content Discovery 2024-05-13 15:42:52 +02:00
Vitor Pamplona 8ca53e9707 Updates jackson 2024-05-10 19:47:40 -04:00
jeremyd 326e38f293 allow relay selection dialog to pick any relays (instead of just write relays) 2024-05-10 15:14:37 -07:00
Vitor Pamplona 6232e2682f Updates firebase 2024-05-09 19:10:19 -04:00
Vitor Pamplona a2363221c6 Fixes Copy Text function of DraftEvents 2024-05-07 09:58:29 -04:00
Vitor Pamplona 1eef457b4e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-05-06 11:32:21 -04:00
Vitor Pamplona fbd88bdeab Fixes top bar lists not updating when following communities and hashtags. 2024-05-06 11:32:14 -04:00
Vitor PamplonaandGitHub 5d25bec1c9 Merge pull request #851 from VASHvic/hide_words_check
show toast error if unable to hide words
2024-05-04 15:59:36 -04:00
VASH 201a6d4462 show toast error if unable to hide words 2024-05-04 19:38:19 +02:00
Vitor PamplonaandGitHub 1345ad3745 Merge pull request #847 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-05-01 13:17:55 -04:00
Crowdin Bot b5f2a2b428 New Crowdin translations by GitHub Action 2024-05-01 17:16:56 +00:00
Vitor Pamplona fe7b9b7930 Updates AGP 2024-05-01 13:09:29 -04:00
Vitor Pamplona b24d3d863d ignores new idea file 2024-05-01 13:08:02 -04:00
Vitor Pamplona 3b7252616b Updates dependencies 2024-05-01 13:07:26 -04:00
Vitor PamplonaandGitHub c76ed3bb53 Merge pull request #846 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-24 17:06:42 -04:00
Crowdin Bot cf775eebfb New Crowdin translations by GitHub Action 2024-04-24 21:05:58 +00:00
Vitor Pamplona 72018dc208 Base support for Relay lists for DMs. 2024-04-24 17:04:13 -04:00
Vitor Pamplona fef635ab39 Fixes the use of Global-active relays in the Global Feed. 2024-04-24 13:54:13 -04:00
Vitor Pamplona 7a243af45c Updates compose, kotlin and okhttp 2024-04-24 09:53:17 -04:00
David Kaspar 94af0eb220 incremented matching group due to added matching group
added nbsp entity
2024-04-23 19:59:12 +02:00
David Kaspar c0aea75c16 added test for html entity numbers to regular expression
added a few common html entities and entity numbers
2024-04-23 17:16:53 +02:00
Vitor PamplonaandGitHub 175b79b291 Merge pull request #844 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-22 16:38:35 -04:00
Crowdin Bot e5b8523bee New Crowdin translations by GitHub Action 2024-04-22 20:32:36 +00:00
Vitor Pamplona 1aecd9cf45 Minimizes costs of keeping track of the number of events received per subscription 2024-04-22 11:16:17 -04:00
Vitor Pamplona 6600a49564 Fixes the lack of refresh when adding hidden words in the Security filters 2024-04-18 19:34:15 -04:00
Vitor Pamplona cdb65640ba Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 17:18:29 -04:00
Vitor Pamplona 0c9e76eeaf adds content type on CardFeedView 2024-04-18 17:17:17 -04:00
Vitor PamplonaandGitHub cfdbd0a9b6 Merge pull request #842 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 16:35:05 -04:00
Crowdin Bot 09b8178a7c New Crowdin translations by GitHub Action 2024-04-18 20:21:04 +00:00
Vitor Pamplona 5ea793eb51 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-18 16:19:21 -04:00
Vitor Pamplona 8cf04967c3 Makes sure only one pepare is run for each video view. 2024-04-18 14:46:01 -04:00
Vitor Pamplona ef363457e8 Moves ClientController executor to a thread 2024-04-18 14:45:28 -04:00
Vitor PamplonaandGitHub e87394f3f7 Merge pull request #841 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-18 10:49:50 -04:00
Crowdin Bot 86ebfe8564 New Crowdin translations by GitHub Action 2024-04-18 14:24:22 +00:00
Vitor Pamplona a2b3cfb991 Increases timeout to Tor connections 2024-04-18 10:22:19 -04:00
Vitor PamplonaandGitHub 1b6aa621cd Merge pull request #840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 17:55:02 -04:00
Crowdin Bot b7f73c6eab New Crowdin translations by GitHub Action 2024-04-17 21:50:53 +00:00
Vitor Pamplona e35fb88ff1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 17:49:00 -04:00
Vitor Pamplona 6ecb3c8e1f - Improves Zap efficiency for large zap splits
- Adds a queue of commands while the relay connects.
- Makes sure only one connection per URL is made when doing splits
- Removes unecessary Amber calls when decrypting private zaps
2024-04-17 17:48:53 -04:00
Vitor Pamplona ff20f0a266 Deleting unecessary logs 2024-04-17 17:47:17 -04:00
Vitor Pamplona a4cc6337f9 Don't use DM relays to find metadata 2024-04-17 17:43:40 -04:00
Vitor Pamplona 202b897029 Refactors AppScreen out of Activity. 2024-04-17 16:25:13 -04:00
Vitor PamplonaandGitHub 79c174b92e Merge pull request #839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-17 12:23:15 -04:00
Crowdin Bot bdd3f19b2c New Crowdin translations by GitHub Action 2024-04-17 15:16:18 +00:00
Vitor Pamplona afcc775d1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-17 11:14:10 -04:00
Vitor Pamplona 8bbf308619 refactors the use of lud16 and lud06 2024-04-17 11:14:03 -04:00
Vitor Pamplona df378937fe Adds a preview for UserNames with emojis 2024-04-17 11:12:51 -04:00
Vitor PamplonaandGitHub 02ab7a3f3f Merge pull request #838 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-16 17:16:22 -04:00
Crowdin Bot 68ba9b3b91 New Crowdin translations by GitHub Action 2024-04-16 21:14:51 +00:00
Vitor Pamplona f62833d1be Updates Firebase and Media3 dependencies 2024-04-16 17:11:52 -04:00
Vitor PamplonaandGitHub 3be246c9cc Merge pull request #835 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-12 17:07:41 -04:00
Crowdin Bot b28546b172 New Crowdin translations by GitHub Action 2024-04-12 21:05:53 +00:00
Vitor Pamplona 0fccfd7f80 clean up 2024-04-12 11:02:25 -04:00
Vitor Pamplona 3f35b57571 Improves Zap error messages to include the lnaddress of the error 2024-04-12 11:02:17 -04:00
Vitor Pamplona 32b9b6c37a Speeds up id calculations for Amber's Intent call.
Only assembles an id if necessary.
2024-04-12 11:01:32 -04:00
Vitor Pamplona 2342da114d Increases Amber signing cache. 2024-04-12 11:00:16 -04:00
Vitor Pamplona ef0d77f8eb Removes hardcoded amber packages as default 2024-04-12 10:59:59 -04:00
Vitor Pamplona 5559b69bdb Displays Zap Split error messages in sequence instead of in multiple popups. 2024-04-12 10:58:32 -04:00
Vitor Pamplona eda25b4cfe Callback uri is not necessary 2024-04-12 10:57:33 -04:00
Vitor Pamplona 9ce14e08fd Fixes a bug with signature-null in the sig of events. 2024-04-12 10:56:38 -04:00
Vitor Pamplona b046fd91cb Only logs external signer calls on debug 2024-04-11 18:30:28 -04:00
Vitor Pamplona 8c9800664f v0.86.5 2024-04-11 18:20:48 -04:00
Vitor Pamplona b90a57220d Enables Mutiny Wallet NWC 2024-04-11 18:18:54 -04:00
Vitor Pamplona d16b0f58bb Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-11 18:04:53 -04:00
Vitor Pamplona a538b66db3 Removes the use of DM relays to find events due to private inbox settings 2024-04-11 18:04:02 -04:00
Vitor Pamplona f04631b0dd Adds vertical scrolling on the Zap page for collaborators. 2024-04-11 18:01:37 -04:00
Vitor Pamplona 6e31cff99c Avoids decrypting existing Nostr events just to add the relay into the relay list. 2024-04-11 18:01:22 -04:00
Vitor Pamplona 1553640c18 Calculates hash in the IO thread from Compose's scope. 2024-04-11 16:34:34 -04:00
Vitor PamplonaandGitHub 68b8f9c82a Merge pull request #834 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-11 13:49:46 -04:00
Crowdin Bot f6cce42028 New Crowdin translations by GitHub Action 2024-04-11 17:47:21 +00:00
Vitor Pamplona 0cbddad9c0 v0.86.4 2024-04-11 13:45:27 -04:00
Vitor PamplonaandGitHub b14154e2b5 Merge pull request #832 from greenart7c3/main
Do not use tor proxy when localhost, fix proxy not being used inside ImageDownloader.kt
2024-04-10 17:30:51 -04:00
greenart7c3 c4250ccd35 fix error when not using proxy 2024-04-10 14:11:11 -03:00
greenart7c3 31516964c8 Do not use tor proxy when localhost, fix proxy not being used inside ImageDownloader.kt 2024-04-10 13:51:48 -03:00
Vitor Pamplona 4722b2a617 Fixes missing Zaps and some DMs on startup 2024-04-10 11:05:21 -04:00
Vitor Pamplona eca5b47ab0 Updates Kotlin version 2024-04-10 10:20:33 -04:00
Vitor Pamplona d38b57025c Updates compose BOM 2024-04-10 09:00:57 -04:00
Vitor Pamplona fa7aa3cf24 Updates AGP 2024-04-10 08:47:05 -04:00
Vitor Pamplona d8e9b4773b v0.86.3 2024-04-09 19:41:22 -04:00
Vitor Pamplona f9a7b13ba1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-04-09 19:36:49 -04:00
Vitor Pamplona ecbf0e404d Fixes missing notifications 2024-04-09 19:36:40 -04:00
Vitor PamplonaandGitHub c2f8df963a Merge pull request #831 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-09 12:23:35 -04:00
Crowdin Bot ff20960bb5 New Crowdin translations by GitHub Action 2024-04-09 16:20:30 +00:00
Vitor Pamplona 8dd1fc2077 Fixing F-droid's Markdown use 2024-04-09 12:18:14 -04:00
Vitor Pamplona bb2fb2b103 Fixes max width of hidden notes 2024-04-09 12:15:27 -04:00
Vitor Pamplona 00a9c49915 - Migrates to the new Markdown Parser.
- Adds Note previews on Markdown
- Adds Custom hashtag icons to markdown.
- Adds URL preview boxes to markdown
- Performance improvements.
2024-04-08 18:53:55 -04:00
Vitor Pamplona d2872cc8bb Refactors the url preview state 2024-04-08 18:34:10 -04:00
Vitor Pamplona d33a1ce14f Fixes clickable route not showing the user 's npub before loading the name. 2024-04-08 18:33:50 -04:00
Vitor Pamplona 31958215be Moves loading of an embed event to thread. 2024-04-08 18:33:31 -04:00
Vitor Pamplona bbbb614718 Moves added charts after NIP19 uris to being nullable. 2024-04-08 18:32:37 -04:00
Vitor Pamplona 776a23c256 Clearing the secondary button design or the encrypted nsec backup 2024-04-08 11:34:33 -04:00
430 changed files with 22570 additions and 8399 deletions
+3
View File
@@ -7,9 +7,12 @@
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/deploymentTargetSelector.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
.DS_Store
/build
/captures
+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.9.22" />
<option name="version" value="1.9.24" />
</component>
</project>
+23 -12
View File
@@ -43,7 +43,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
- [x] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
@@ -55,6 +55,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
@@ -66,17 +67,20 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Git Stuff (NIP-34/Draft)
- [x] Git Stuff (NIP-34)
- [ ] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [x] Note Edits (NIP-37/Draft)
- [x] Edits (NIP-37/Draft)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
@@ -86,31 +90,32 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Wiki (NIP-54)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] Binary Blobs (NIP-95/Draft)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
@@ -120,9 +125,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] De-googled F-Droid flavor
- [x] Multiple Accounts
- [x] Markdown Support
- [x] FHIR Payloads (kind:82)
- [ ] Decentralized Wiki (kind:30818)
- [ ] Embed events
- [x] Medical Data (NIP-xx/Draft)
- [x] Embed events (NIP-xx/Draft)
- [x] Draft Events (NIP-xx/Draft)
- [ ] Event Sets (NIP-xx/Draft)
- [ ] Topical Notes (NIP-xx/Draft)
- [ ] Relationship Status (NIP-xx/Draft)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
+4 -5
View File
@@ -12,9 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 365
versionName "0.86.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"a704a11334ed4fe6fc6ee6f8856f6f005da33644770616f1437f8b2b488b52b1\""
versionCode 379
versionName "0.88.2"
buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -143,7 +143,7 @@ android {
composeOptions {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
kotlinCompilerExtensionVersion "1.5.14"
}
packagingOptions {
resources {
@@ -151,7 +151,6 @@ android {
}
}
lint {
disable 'MissingTranslation'
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TransformedText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
@@ -47,6 +48,136 @@ class UrlUserTagTransformationTest {
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
fun debugCursor(
original: String,
transformedText: TransformedText,
offset: Int,
): String {
val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
fun debugCursorReverse(
original: String,
transformedText: TransformedText,
offsetTransformed: Int,
): String {
val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(original),
Color.Red,
)
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0))
assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1))
assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2))
assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4))
assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5))
assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6))
assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8))
assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9))
assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10))
assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12))
assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13))
assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14))
assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15))
assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17))
assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18))
assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19))
assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21))
assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22))
assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23))
assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25))
assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26))
assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27))
assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64))
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15))
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
}
@Test
fun transformationText() {
val user =
@@ -70,11 +201,11 @@ class UrlUserTagTransformationTest {
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
@@ -106,12 +237,12 @@ class UrlUserTagTransformationTest {
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
@@ -120,7 +251,7 @@ class UrlUserTagTransformationTest {
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
@@ -56,7 +56,7 @@ object PushDistributorHandler : PushDistributorActions {
}
override fun getSavedDistributor(): String {
return unifiedPush.getDistributor(appContext)
return unifiedPush.getSavedDistributor(appContext) ?: ""
}
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
@@ -43,13 +43,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.Material3RichText
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
@@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNoti
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -77,7 +79,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringResource(id = R.string.select_push_server),
title = stringRes(id = R.string.select_push_server),
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
@@ -99,14 +101,20 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
} else {
AlertDialog(
onDismissRequest = { distributorPresent = true },
title = { Text(stringResource(R.string.push_server_install_app)) },
title = { Text(stringRes(R.string.push_server_install_app)) },
text = {
Material3RichText(
val content = stringRes(R.string.push_server_install_app_description)
val astNode =
remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
}
RichText(
style = RichTextStyle().resolveDefaults(),
renderer = null,
) {
Markdown(
content = stringResource(R.string.push_server_install_app_description),
)
BasicMarkdown(astNode)
}
},
confirmButton = {
@@ -120,7 +128,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
sharedPreferencesViewModel.dontShowPushNotificationSelector()
},
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
Text(stringRes(R.string.quick_action_dont_show_again_button))
}
Button(
onClick = { distributorPresent = true },
@@ -134,7 +142,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
Text(stringRes(R.string.error_dialog_button_ok))
}
}
}
@@ -160,20 +168,19 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
}
val readableListWithExplainer =
PushDistributorHandler.formattedDistributorNames()
PushDistributorHandler
.formattedDistributorNames()
.mapIndexed { index, name ->
TitleExplainer(
name,
stringResource(id = R.string.push_server_uses_app_explainer, list[index]),
stringRes(id = R.string.push_server_uses_app_explainer, list[index]),
)
}
.plus(
}.plus(
TitleExplainer(
stringResource(id = R.string.push_server_none),
stringResource(id = R.string.push_server_none_explainer),
stringRes(id = R.string.push_server_none),
stringRes(id = R.string.push_server_none_explainer),
),
)
.toImmutableList()
).toImmutableList()
onInner(
currentDistributor,
@@ -36,6 +36,7 @@ fun TranslatableRichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) = ExpandableRichTextViewer(
@@ -46,6 +47,7 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
id,
callbackUri,
accountViewModel,
nav,
)
@@ -40,6 +40,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
import kotlin.time.measureTimedValue
@@ -53,7 +54,9 @@ class Amethyst : Application() {
val videoCache: VideoCache by lazy {
val newCache = VideoCache()
newCache.initFileCache(this)
runBlocking {
newCache.initFileCache(this@Amethyst)
}
newCache
}
@@ -77,6 +77,7 @@ private object PrefKeys {
const val NOSTR_PUBKEY = "nostr_pubkey"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
@@ -91,21 +92,14 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
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"
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val PENDING_ATTESTATIONS = "pending_attestations"
@@ -147,7 +141,7 @@ object LocalPreferences {
Event.mapper.readValue<List<AccountInfo>>(it)
}
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -163,6 +157,8 @@ object LocalPreferences {
}
savedAccounts = migrated
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
}
}
}
@@ -243,7 +239,8 @@ object LocalPreferences {
* deleted
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) =
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
withContext(Dispatchers.IO) {
val userPrefs = encryptedPreferences(accountInfo.npub)
userPrefs.edit().clear().commit()
@@ -256,17 +253,17 @@ object LocalPreferences {
updateCurrentAccount(savedAccounts().elementAt(0).npub)
}
}
}
suspend fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts()
}
fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
suspend fun saveToEncryptedStorage(account: Account) =
suspend fun saveToEncryptedStorage(account: Account) {
Log.d("LocalPreferences", "Saving to encrypted storage")
withContext(Dispatchers.IO) {
checkNotInMainThread()
@@ -284,6 +281,7 @@ object LocalPreferences {
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
putString(
PrefKeys.LANGUAGE_PREFS,
Event.mapper.writeValueAsString(account.languagePreferences),
@@ -318,7 +316,7 @@ object LocalPreferences {
Event.mapper.writeValueAsString(account.backupContactList),
)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
@@ -338,20 +336,19 @@ object LocalPreferences {
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(account.pendingAttestations),
Event.mapper.writeValueAsString(account.pendingAttestations.value),
)
}
.apply()
}.apply()
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
return currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
with(prefs.edit()) {
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
apply()
@@ -359,6 +356,7 @@ object LocalPreferences {
}
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
Log.d("LocalPreferences", "Load shared settings")
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.mapper.readValue<Settings>(it) }
@@ -393,8 +391,10 @@ object LocalPreferences {
}
}
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? =
withContext(Dispatchers.IO) {
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
Log.d("LocalPreferences", "Load account from file")
return withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
@@ -410,6 +410,7 @@ object LocalPreferences {
?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
@@ -526,7 +527,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
@@ -577,6 +578,7 @@ object LocalPreferences {
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
localRelayServers = localRelayServers,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
@@ -591,7 +593,7 @@ object LocalPreferences {
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
@@ -600,7 +602,7 @@ object LocalPreferences {
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = pendingAttestations ?: emptyMap(),
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
)
// Loads from DB
@@ -614,4 +616,5 @@ object LocalPreferences {
return@with account
}
}
}
}
@@ -58,6 +58,7 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Stable
@@ -104,7 +105,7 @@ class ServiceManager {
}
if (myAccount != null) {
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
val relaySet = myAccount.connectToRelays.value
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
@@ -112,10 +113,10 @@ class ServiceManager {
collectorJob = null
collectorJob =
scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
myAccount.connectToRelaysFlow.collectLatest {
delay(500)
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
Client.reconnect(it, onlyIfChanged = true)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
@@ -69,10 +70,6 @@ class AntiSpamFilter {
if (
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19Bech32.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", " | ")}",
@@ -81,6 +78,10 @@ class AntiSpamFilter {
// Log down offenders
logOffender(hash, event)
if (relay != null) {
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
}
liveSpam.invalidateData()
return true
@@ -100,7 +101,7 @@ class AntiSpamFilter {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
spammer.duplicatedMessages += event.id
}
}
@@ -26,6 +26,8 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.data.DeletionIndex
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -56,6 +58,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -88,18 +91,25 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RecommendRelayEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
@@ -113,10 +123,12 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -136,6 +148,76 @@ object LocalCache {
val deletionIndex = DeletionIndex()
private val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
private val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
scope: CoroutineScope,
): LatestByKindWithETag<T> {
var eTagList = observablesByKindAndETag.get(kind)
if (eTagList == null) {
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
observablesByKindAndETag.put(kind, eTagList)
}
val value = eTagList.get(eventId)
return if (value != null) {
value
} else {
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
}
}
obj
} as LatestByKindWithETag<T>
}
fun <T : Event> observeAuthor(
kind: Int,
pubkey: HexKey,
scope: CoroutineScope,
): LatestByKindAndAuthor<T> {
var authorObsList = observablesByKindAndAuthor.get(kind)
if (authorObsList == null) {
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
observablesByKindAndAuthor.put(kind, authorObsList)
}
val value = authorObsList.get(pubkey)
return if (value != null) {
value
} else {
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
if (obj == newObject) {
// initialize
scope.launch(Dispatchers.IO) {
obj.init()
}
}
obj
} as LatestByKindAndAuthor<T>
}
private fun updateObservables(event: Event) {
val observablesOfKind = observablesByKindAndETag[event.kind] ?: return
event.forEachTaggedEvent {
observablesOfKind[it]?.updateIfMatches(event)
}
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
}
fun checkGetOrCreateUser(key: String): User? {
// checkNotInMainThread()
@@ -171,6 +253,22 @@ object LocalCache {
return channels.get(key)
}
fun getNoteIfExists(event: Event): Note? {
return if (event is AddressableEvent) {
getAddressableNoteIfExists(event.addressTag())
} else {
getNoteIfExists(event.id)
}
}
fun getOrCreateNote(event: Event): Note {
return if (event is AddressableEvent) {
getOrCreateAddressableNote(event.address())
} else {
getOrCreateNote(event.id)
}
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
@@ -312,6 +410,8 @@ object LocalCache {
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
updateObservables(event)
}
}
@@ -350,7 +450,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -367,6 +466,148 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Log.d("TN", "New Response ${event.taggedEvents().joinToString(", ") { it }}}")
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90StatusEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90UserDiscoveryResponseEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
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
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: GitPatchEvent,
relay: Relay? = null,
@@ -383,7 +624,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -408,7 +648,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -433,7 +672,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -471,7 +709,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -506,7 +743,6 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -564,7 +800,7 @@ object LocalCache {
.mapNotNull { checkGetOrCreateNote(it) }
is DraftEvent -> {
event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().mapNotNull { checkGetOrCreateAddressableNote(it.toTag()) }
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
}
else -> emptyList<Note>()
@@ -587,7 +823,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -683,6 +918,27 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: ChatMessageRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: SearchRelayListEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CommunityDefinitionEvent,
relay: Relay?,
@@ -1272,7 +1528,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1311,7 +1566,6 @@ object LocalCache {
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
@@ -1591,7 +1845,7 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(
fun consume(
event: SealedGossipEvent,
relay: Relay?,
) {
@@ -1606,7 +1860,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
note.loadEvent(event.copyNoContent(), author, emptyList())
refreshObservers(note)
}
@@ -1626,7 +1880,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
note.loadEvent(event.copyNoContent(), author, emptyList())
refreshObservers(note)
}
@@ -1843,6 +2097,26 @@ object LocalCache {
notes.forEach { _, it -> it.clearLive() }
addressables.forEach { _, it -> it.clearLive() }
users.forEach { _, it -> it.clearLive() }
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
observablesByKindAndETag.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
observablesByKindAndAuthor.forEach { _, list ->
list.forEach { key, value ->
if (value.canDelete()) {
list.remove(key)
}
}
}
}
fun pruneOldAndHiddenMessages(account: Account) {
@@ -2070,6 +2344,7 @@ object LocalCache {
val live: LocalCacheLiveData = LocalCacheLiveData()
private fun refreshObservers(newNote: Note) {
updateObservables(newNote.event as Event)
live.invalidateData(newNote)
}
@@ -2098,7 +2373,7 @@ object LocalCache {
}
}
private fun consume(
fun consume(
event: DraftEvent,
relay: Relay?,
) {
@@ -2240,6 +2515,7 @@ object LocalCache {
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ChatMessageRelayListEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
@@ -2252,7 +2528,10 @@ object LocalCache {
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
@@ -2274,6 +2553,11 @@ object LocalCache {
}
}
is LnZapRequestEvent -> consume(event)
is NIP90StatusEvent -> consume(event, relay)
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay)
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay)
is NIP90UserDiscoveryResponseEvent -> consume(event, relay)
is NIP90UserDiscoveryRequestEvent -> consume(event, relay)
is LnZapPaymentRequestEvent -> consume(event)
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
@@ -2282,6 +2566,7 @@ object LocalCache {
is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PrivateOutboxRelayListEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
@@ -2293,10 +2578,8 @@ object LocalCache {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
consume(event)
}
is SealedGossipEvent -> consume(event, relay)
is SearchRelayListEvent -> consume(event, relay)
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is TextNoteModificationEvent -> consume(event, relay)
@@ -2314,7 +2597,14 @@ object LocalCache {
}
fun hasConsumed(notificationEvent: Event): Boolean {
return notes.containsKey(notificationEvent.id)
return if (notificationEvent is AddressableEvent) {
val note = addressables.get(notificationEvent.addressTag())
val noteEvent = note?.event
noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt()
} else {
val note = notes.get(notificationEvent.id)
note?.event != null
}
}
}
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
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.actions.relays.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -71,13 +72,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
class AddressableNote(
val address: ATag,
) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
@@ -95,9 +95,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
fun dTag(): String? = (event as? AddressableEvent)?.dTag()
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
@@ -109,7 +107,9 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
}
@Stable
open class Note(val idHex: String) {
open class Note(
val idHex: String,
) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: EventInterface? = null
@@ -154,7 +154,7 @@ open class Note(val idHex: String) {
Nip19Bech32.createNEvent(
host.id,
host.pubKey,
host.kind(),
host.kind,
relays.firstOrNull()?.url,
)
} else {
@@ -165,14 +165,12 @@ open class Note(val idHex: String) {
}
}
fun toNostrUri(): String {
return "nostr:${toNEvent()}"
}
fun toNostrUri(): String = "nostr:${toNEvent()}"
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return if (
fun channelHex(): HexKey? =
if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
@@ -187,7 +185,6 @@ open class Note(val idHex: String) {
} else {
null
}
}
open fun address(): ATag? = null
@@ -210,96 +207,6 @@ open class Note(val idHex: String) {
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: it
.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
)
.apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author,
)
cachedSignatures[this] = mySignature
return mySignature
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
@@ -351,7 +258,9 @@ open class Note(val idHex: String) {
if (repliesChanged) liveSet?.innerReplies?.invalidateData()
if (reactionsChanged) liveSet?.innerReactions?.invalidateData()
if (boostsChanged) liveSet?.innerBoosts?.invalidateData()
if (reportsChanged) liveSet?.innerReports?.invalidateData()
if (reportsChanged) {
flowSet?.reports?.invalidateData()
}
if (zapsChanged) liveSet?.innerZaps?.invalidateData()
return toBeRemoved
@@ -383,7 +292,7 @@ open class Note(val idHex: String) {
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
}
@@ -492,10 +401,10 @@ open class Note(val idHex: String) {
if (reportsByAuthor == null) {
reports = reports + Pair(author, listOf(note))
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
} else if (!reportsByAuthor.contains(note)) {
reports = reports + Pair(author, reportsByAuthor + note)
liveSet?.innerReports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
@@ -509,7 +418,7 @@ open class Note(val idHex: String) {
fun addRelay(relay: Relay) {
if (relay.brief !in relays) {
addRelaySync(relay.brief)
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
}
@@ -615,39 +524,30 @@ open class Note(val idHex: String) {
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
fun getReactionBy(user: User): String? =
reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun isBoostedBy(user: User): Boolean = boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
fun hasReportsBy(user: User): Boolean {
return reports[user]?.isNotEmpty() ?: false
}
fun hasReportsBy(user: User): Boolean = reports[user]?.isNotEmpty() ?: false
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.count { it.key.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
fun reportsBy(users: Set<HexKey>): List<Note> {
return reports
fun reportsBy(users: Set<HexKey>): List<Note> =
reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
}.flatten()
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
@@ -727,8 +627,8 @@ open class Note(val idHex: String) {
)
}
fun hasPledgeBy(user: User): Boolean {
return replies
fun hasPledgeBy(user: User): Boolean =
replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue =
@@ -742,10 +642,9 @@ open class Note(val idHex: String) {
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
fun pledgedAmountByOthers(): BigDecimal =
replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
@@ -755,9 +654,7 @@ open class Note(val idHex: String) {
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
}.sumOf { it }
fun hasAnyReports(): Boolean {
val dayAgo = TimeUtils.oneDayAgo()
@@ -768,8 +665,8 @@ open class Note(val idHex: String) {
)
}
fun isNewThread(): Boolean {
return (
fun isNewThread(): Boolean =
(
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
@@ -777,29 +674,20 @@ open class Note(val idHex: String) {
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn }
fun hasReacted(
loggedIn: User,
content: String,
): Boolean {
return reactedBy(loggedIn, content).isNotEmpty()
}
): Boolean = reactedBy(loggedIn, content).isNotEmpty()
fun reactedBy(
loggedIn: User,
content: String,
): List<Note> {
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
): List<Note> = reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
fun reactedBy(loggedIn: User): List<String> {
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
}
fun reactedBy(loggedIn: User): List<String> = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
return boosts.firstOrNull {
@@ -807,9 +695,7 @@ open class Note(val idHex: String) {
} != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
fun boostedBy(loggedIn: User): List<Note> = boosts.filter { it.author == loggedIn }
fun moveAllReferencesTo(note: AddressableNote) {
// migrates these comments to a new version
@@ -854,33 +740,38 @@ open class Note(val idHex: String) {
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val hash = thisEvent.pubKey().hashCode()
val isBoostedNoteHidden =
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
// if the author is hidden by spam or blocked
if (accountChoices.hiddenUsersHashCodes.contains(hash) ||
accountChoices.spammersHashCodes.contains(hash)
) {
return true
}
// if the post is sensitive and the user doesn't want to see sensitive content
if (accountChoices.showSensitiveContent == false && thisEvent.isSensitive()) {
return true
}
// if this is a repost, consider the inner event.
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
if (replyTo?.lastOrNull()?.isHiddenFor(accountChoices) == true) {
return true
}
}
val isHiddenByWord =
if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.containsAny(accountChoices.hiddenWordsCase)
}
} else {
false
if (thisEvent is BaseTextNoteEvent) {
if (accountChoices.hiddenWordsCase.isNotEmpty() && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) {
return true
}
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden ||
isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
return false
}
var liveSet: NoteLiveSet? = null
@@ -950,28 +841,35 @@ open class Note(val idHex: String) {
}
@Stable
class NoteFlowSet(u: Note) {
class NoteFlowSet(
u: Note,
) {
// Observers line up here.
val metadata = NoteBundledRefresherFlow(u)
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
fun isInUse(): Boolean {
return metadata.stateFlow.subscriptionCount.value > 0
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
reports.destroy()
relays.destroy()
}
}
@Stable
class NoteLiveSet(u: Note) {
class NoteLiveSet(
u: Note,
) {
// Observers line up here.
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val innerModifications = NoteBundledRefresherLiveData(u)
@@ -980,8 +878,6 @@ class NoteLiveSet(u: Note) {
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val reports = innerReports.map { it }
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
@@ -993,8 +889,7 @@ class NoteLiveSet(u: Note) {
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}
.distinctUntilChanged()
}.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
@@ -1004,22 +899,17 @@ class NoteLiveSet(u: Note) {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}
.distinctUntilChanged()
}.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val relayInfo = innerRelays.map { it.note.relays }
val content = innerMetadata.map { it.note.event?.content() ?: "" }
fun isInUse(): Boolean {
return metadata.hasObservers() ||
fun isInUse(): Boolean =
metadata.hasObservers() ||
reactions.hasObservers() ||
boosts.hasObservers() ||
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
@@ -1028,15 +918,12 @@ class NoteLiveSet(u: Note) {
boostCount.hasObservers() ||
innerOts.hasObservers() ||
innerModifications.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
innerOts.destroy()
innerModifications.destroy()
@@ -1044,8 +931,11 @@ class NoteLiveSet(u: Note) {
}
@Stable
class NoteBundledRefresherFlow(val note: Note) {
class NoteBundledRefresherFlow(
val note: Note,
) {
// Refreshes observers in batches.
// TODO: Replace the bundler for .sample
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(NoteState(note))
@@ -1065,7 +955,9 @@ class NoteBundledRefresherFlow(val note: Note) {
}
@Stable
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
class NoteBundledRefresherLiveData(
val note: Note,
) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -1092,7 +984,10 @@ class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteSta
}
@Stable
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
class NoteLoadingLiveData<Y>(
val note: Note,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
@@ -1112,18 +1007,20 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
}
}
@Immutable class NoteState(val note: Note)
@Immutable class NoteState(
val note: Note,
)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@Immutable
data class RelayBriefInfo(
class RelayBriefInfo(
val url: String,
val displayUrl: String =
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
)
) {
val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern()
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
}
fun get(url: String): RelayBriefInfo {
val info = cache[url]
@@ -28,12 +28,5 @@ data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
)
@@ -34,17 +34,20 @@ data class Settings(
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
val dontShowPushNotificationSelector: Boolean = false,
val dontAskForNotificationPermissions: Boolean = false,
val featureSet: FeatureSetType = FeatureSetType.COMPLETE,
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
)
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
enum class ThemeType(
val screenCode: Int,
val resourceId: Int,
) {
SYSTEM(0, R.string.system),
LIGHT(1, R.string.light),
DARK(2, R.string.dark),
}
fun parseThemeType(code: Int?): ThemeType {
return when (code) {
fun parseThemeType(code: Int?): ThemeType =
when (code) {
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
ThemeType.DARK.screenCode -> ThemeType.DARK
@@ -52,21 +55,28 @@ fun parseThemeType(code: Int?): ThemeType {
ThemeType.SYSTEM
}
}
}
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
enum class ConnectivityType(
val prefCode: Boolean?,
val screenCode: Int,
val resourceId: Int,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never),
}
enum class FeatureSetType(val screenCode: Int, val resourceId: Int) {
enum class FeatureSetType(
val screenCode: Int,
val resourceId: Int,
) {
COMPLETE(0, R.string.ui_feature_set_type_complete),
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
PERFORMANCE(2, R.string.ui_feature_set_type_performance),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) {
fun parseConnectivityType(code: Boolean?): ConnectivityType =
when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
@@ -74,10 +84,9 @@ fun parseConnectivityType(code: Boolean?): ConnectivityType {
ConnectivityType.ALWAYS
}
}
}
fun parseConnectivityType(screenCode: Int): ConnectivityType {
return when (screenCode) {
fun parseConnectivityType(screenCode: Int): ConnectivityType =
when (screenCode) {
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
@@ -85,39 +94,40 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
ConnectivityType.ALWAYS
}
}
}
fun parseFeatureSetType(screenCode: Int): FeatureSetType {
return when (screenCode) {
fun parseFeatureSetType(screenCode: Int): FeatureSetType =
when (screenCode) {
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
FeatureSetType.PERFORMANCE.screenCode -> FeatureSetType.PERFORMANCE
else -> {
FeatureSetType.COMPLETE
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
enum class BooleanType(
val prefCode: Boolean?,
val screenCode: Int,
val reourceId: Int,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never),
}
fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) {
fun parseBooleanType(code: Boolean?): BooleanType =
when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) {
fun parseBooleanType(screenCode: Int): BooleanType =
when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
object ThreadLevelCalculator {
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
private fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
}
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
note: Note,
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
createdAt = note.createdAt(),
author = note.author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: replyLevelSignature(
it,
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == note.author && note.createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
note.idHex.substring(0, 8)
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
} else if (note.author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = note.createdAt(),
author = note.author,
)
cachedSignatures[note] = mySignature
return mySignature
}
fun replyLevel(
note: Note,
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
): Int {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
object CreatedAtComparator : Comparator<Note> {
override fun compare(
first: Note?,
second: Note?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
override fun compare(
first: AddressableNote?,
second: AddressableNote?,
): Int {
val firstEvent = first?.event
val secondEvent = second?.event
return if (firstEvent == null && secondEvent == null) {
0
} else if (firstEvent == null) {
1
} else if (secondEvent == null) {
-1
} else {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
}
}
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindAndAuthor<T : Event>(private val kind: Int, private val pubkey: String) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
}
suspend fun init() {
val latestNote =
if ((kind in 10000..19999) || (kind in 30000..39999)) {
LocalCache.addressables.maxOrNullOf(
filter = { idHex: String, note: AddressableNote ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparatorAddresses,
)?.event as? T
} else {
LocalCache.notes.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.pubKey() == pubkey
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
}
_latest.tryEmit(latestNote)
}
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.observables
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindWithETag<T : Event>(private val kind: Int, private val eTag: String) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
fun updateIfMatches(event: T) {
if (matches(event)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
}
}
}
fun canDelete(): Boolean {
return _latest.subscriptionCount.value == 0
}
suspend fun init() {
val latestNote =
LocalCache.notes.maxOrNullOf(
filter = { idHex: String, note: Note ->
note.event?.let {
it.kind() == kind && it.isTaggedEvent(eTag)
} == true
},
comparator = CreatedAtComparator,
)?.event as? T
_latest.tryEmit(latestNote)
}
}
@@ -31,9 +31,9 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
@@ -43,23 +43,9 @@ class BlurHashFetcher(
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap =
BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt(),
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
@@ -28,14 +28,18 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(50)
fun getCached(content: String): RichTextViewerState? = richTextCache[content]
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
val cached = richTextCache[content]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags)
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(content, newUrls)
newUrls
}
@@ -28,6 +28,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.Event
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
@@ -46,9 +47,7 @@ data class CashuToken(
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
fun cached(token: String): GenericLoadable<CashuToken> {
return cashuCache[token] ?: GenericLoadable.Loading()
}
fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<CashuToken> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
@@ -150,7 +149,12 @@ class CashuProcessor {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build()
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
@@ -163,13 +167,19 @@ class CashuProcessor {
feeCost,
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
@@ -177,8 +187,8 @@ class CashuProcessor {
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
@@ -203,7 +213,12 @@ class CashuProcessor {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder().url(url).post(requestBody).build()
val request =
Request
.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
@@ -213,21 +228,28 @@ class CashuProcessor {
if (successful) {
onSuccess(
context.getString(R.string.cashu_successful_redemption),
context.getString(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(
context,
R.string.cashu_successful_redemption_explainer,
token.totalAmount.toString(),
fees.toString(),
),
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
val msg =
tree
?.get("detail")
?.asText()
?.split('.')
?.getOrNull(0)
?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
},
)
}
@@ -235,8 +257,8 @@ class CashuProcessor {
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message),
stringRes(context, R.string.cashu_successful_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
@@ -39,6 +39,7 @@ object HttpClientManager {
var proxyChangeListeners = ArrayList<() -> Unit>()
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
// fires off every time value of the property changes
private var internalProxy: Proxy? by
@@ -58,6 +59,10 @@ object HttpClientManager {
}
}
fun getDefaultProxy(): Proxy? {
return this.internalProxy
}
fun setDefaultTimeout(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
@@ -72,7 +77,7 @@ object HttpClientManager {
proxy: Proxy?,
timeout: Duration,
): OkHttpClient {
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
@@ -98,11 +103,28 @@ object HttpClientManager {
}
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
fun getHttpClientForUrl(url: String): OkHttpClient {
// TODO: How to identify relays on the local network?
val isLocalHost = url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")
return if (isLocalHost) {
getHttpClient(false)
} else {
getHttpClient()
}
}
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
return if (useProxy) {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
}
defaultHttpClient!!
} else {
if (this.defaultHttpClientWithoutProxy == null) {
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
}
defaultHttpClientWithoutProxy!!
}
return defaultHttpClient!!
}
fun initProxy(
@@ -25,12 +25,9 @@ import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05NostrAddressVerifier() {
class Nip05NostrAddressVerifier {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -46,7 +43,7 @@ class Nip05NostrAddressVerifier() {
suspend fun fetchNip05Json(
nip05: String,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
checkNotInMainThread()
@@ -60,52 +57,39 @@ class Nip05NostrAddressVerifier() {
try {
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClientManager.getHttpClient()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
HttpClientManager
.getHttpClient()
.newBuilder()
.followRedirects(false)
.build()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
checkNotInMainThread()
.execute()
.use {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
override fun onFailure(
call: Call,
e: java.io.IOException,
) {
onError(
"Could not resolve $url. Check if the server is up and if the address $nip05 is correct",
)
e.printStackTrace()
}
},
)
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve '$url': ${e.message}")
onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
onSuccess: (String) -> Unit,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) {
// check fails on tests
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call
@@ -32,17 +33,26 @@ import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
open class RetrieveResult(val time: Long)
open class RetrieveResult(
val time: Long,
)
class RetrieveResultError(val error: Nip11Retriever.ErrorCode, val msg: String? = null) : RetrieveResult(TimeUtils.now())
class RetrieveResultError(
val error: Nip11Retriever.ErrorCode,
val msg: String? = null,
) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(val data: Nip11RelayInformation) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(
val data: Nip11RelayInformation,
) : RetrieveResult(TimeUtils.now())
val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
val retriever = Nip11Retriever()
class RetrieveResultLoading : RetrieveResult(TimeUtils.now())
private val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
private val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
@@ -52,47 +62,53 @@ object Nip11CachedRetriever {
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
checkNotInMainThread()
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
if (doc is RetrieveResultSuccess) {
onInfo(doc.data)
} else if (doc is RetrieveResultLoading) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
// just wait.
} else {
retrieve(url, dirtyUrl, onInfo, onError)
}
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retrieve(url, dirtyUrl, onInfo, onError)
}
}
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
retrieve(url, dirtyUrl, onInfo, onError)
}
}
private suspend fun retrieve(
url: String,
dirtyUrl: String,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
relayInformationDocumentCache.put(url, RetrieveResultLoading())
retriever.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
checkNotInMainThread()
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
class Nip11Retriever {
@@ -103,14 +119,6 @@ class Nip11Retriever {
FAIL_WITH_HTTP_STATUS,
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
@@ -120,9 +128,14 @@ class Nip11Retriever {
checkNotInMainThread()
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClientManager.getHttpClient()
HttpClientManager
.getHttpClientForUrl(dirtyUrl)
.newCall(request)
.enqueue(
object : Callback {
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -39,11 +40,13 @@ import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
@@ -56,10 +59,12 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -72,8 +77,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
fun createAccountContactListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
@@ -82,10 +87,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
limit = 1,
),
)
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
fun createAccountMetadataFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
@@ -94,24 +98,22 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
limit = 1,
),
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
fun createAccountRelayListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5,
limit = 10,
),
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
@@ -119,6 +121,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
PrivateOutboxRelayListEvent.KIND,
SearchRelayListEvent.KIND,
FileServersEvent.KIND,
MuteListEvent.KIND,
PeopleListEvent.KIND,
),
@@ -128,8 +134,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountSettingsFilter(): TypedFilter {
return TypedFilter(
fun createAccountSettingsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
@@ -138,10 +144,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
limit = 100,
),
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
fun createAccountReportsFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
@@ -154,10 +159,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
?.relayList,
),
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
fun createAccountLastPostsListFilter(): TypedFilter =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
@@ -165,7 +169,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
limit = 400,
),
)
}
fun createNotificationFilter(): TypedFilter {
val since =
@@ -173,8 +176,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -206,7 +208,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
?.followList
?.get(account.defaultNotificationFollowList.value)
?.relayList
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
return TypedFilter(
@@ -237,6 +239,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
JsonFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get("&&((GIFTWRAPS_EOSE))&&")
?.relayList
?.mapValues {
EOSETime(it.value.time - TimeUtils.twoDays())
},
),
)
@@ -249,6 +259,13 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
relayUrl,
time,
)
latestEOSEs.addOrUpdate(
account.userProfile(),
"&&((GIFTWRAPS_EOSE))&&",
relayUrl,
time,
)
} else {
hasLoadedTheBasics[account.userProfile()] = true
@@ -264,46 +281,83 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
if (LocalCache.justVerify(event)) {
when (event) {
is PrivateOutboxRelayListEvent -> {
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
event.privateTags(account.signer) {
LocalCache.justConsume(event, relay)
}
}
}
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
if (!event.isDeleted()) {
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
val noteEvent = note?.event
if (noteEvent != null) {
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
LocalCache.consume(event, relay)
}
} else {
// decrypts
event.cachedDraft(account.signer) {}
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(event, relay)
LocalCache.justConsume(event, relay)
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) { this.consume(it, relay) }
val noteEvent = note?.event as? GiftWrapEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
LocalCache.justConsume(noteEvent, relay)
noteEvent.cachedGift(account.signer) {
this.consume(it, relay)
}
}
} else {
// new event
event.cachedGift(account.signer) { this.consume(it, relay) }
LocalCache.justConsume(event, relay)
}
}
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
val noteEvent = note?.event as? SealedGossipEvent
if (noteEvent != null) {
if (relay.brief !in note.relays) {
// adds the relay to seal and inner chat
LocalCache.consume(noteEvent, relay)
noteEvent.cachedGossip(account.signer) {
LocalCache.justConsume(it, relay)
}
}
} else {
// new event
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
LocalCache.justConsume(event, relay)
}
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
if (note?.event == null) {
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
LocalCache.justConsume(event, relay)
}
}
@@ -345,8 +399,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
}
}
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
override fun updateChannelFilters() =
if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everything about the user logged in
accountChannel.typedFilters =
listOfNotNull(
@@ -360,8 +414,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
} else {
// just the basics.
accountChannel.typedFilters =
@@ -370,10 +423,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createAccountRelayListFilter(),
createAccountSettingsFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}
override fun auth(
relay: Relay,
@@ -52,7 +52,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
filter =
JsonFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.map { it },
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
latestEOSEs.users[account.userProfile()]
@@ -103,7 +103,6 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -80,7 +80,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
return TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
@@ -99,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
return followingEvents.map {
TypedFilter(
// Metadata comes from any relay
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
@@ -43,9 +43,9 @@ abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
data class Counter(val subscriptionId: String, val eventKind: Int, var counter: Int)
private var eventCounter = mapOf<String, Counter>()
private var eventCounter = mapOf<Int, Counter>()
var changingFilters = AtomicBoolean()
private var active: Boolean = false
@@ -54,11 +54,18 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter.forEach {
Log.d(
"STATE DUMP ${this.javaClass.simpleName}",
"Received Events ${it.key}: ${it.value.counter}",
"Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}",
)
}
}
fun hashCodeFields(
str1: String,
str2: Int,
): Int {
return 31 * str1.hashCode() + str2.hashCode()
}
private val clientListener =
object : Client.Listener() {
override fun onEvent(
@@ -68,12 +75,12 @@ abstract class NostrDataSource(val debugName: String) {
afterEOSE: Boolean,
) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
val key = hashCodeFields(subscriptionId, event.kind)
val keyValue = eventCounter[key]
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1))
}
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
@@ -85,19 +92,6 @@ abstract class NostrDataSource(val debugName: String) {
}
}
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// if (subscriptions.containsKey(subscriptionId)) {
// Log.e(
// this@NostrDataSource.javaClass.simpleName,
// "Relay OnError ${relay.url}: ${error.message}"
// )
// }
}
override fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
@@ -200,14 +194,12 @@ abstract class NostrDataSource(val debugName: String) {
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
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()
}
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -131,6 +132,25 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultDiscoveryFollowList.value)
?.relayList,
),
),
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
@@ -404,6 +424,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters =
createLiveStreamFilter()
.plus(createNIP89Filter(listOf("5300")))
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
@@ -417,6 +438,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
createPublicChatsGeohashesFilter(),
),
)
.toList()
.ifEmpty { null }
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null
@@ -61,6 +62,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
@@ -64,6 +65,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
WikiNoteEvent.KIND,
),
limit = 200,
),
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -77,7 +78,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
JsonFilter(
kinds =
@@ -94,6 +95,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PinListEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
limit = 400,
@@ -118,12 +120,15 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -154,12 +159,15 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
kinds =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
),
tags =
mapOf(
@@ -196,6 +204,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
),
tags =
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlin.coroutines.cancellation.CancellationException
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
@@ -137,7 +138,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
limit = 1000,
),
),
TypedFilter(
@@ -175,6 +176,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
WikiNoteEvent.KIND,
),
search = mySearchString,
limit = 100,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -63,7 +63,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ChannelCreateEvent.KIND),
@@ -86,7 +86,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
@@ -60,7 +62,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return groupByEOSEPresence(addressesToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
@@ -82,7 +84,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
@@ -110,7 +112,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
it.address()?.let { aTag ->
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -120,7 +122,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(aTag.kind),
@@ -142,7 +144,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
@@ -161,21 +163,22 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
limit = 10000,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
limit = 100,
),
),
)
@@ -190,9 +193,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
@@ -221,7 +225,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
// downloads linked events to this event.
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
ids = interestedEvents.toList(),
@@ -240,7 +244,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
@@ -249,7 +253,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
addressesToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
@@ -21,16 +21,17 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
private var usersToWatch = setOf<User>()
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
@@ -41,10 +42,10 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
),
@@ -64,16 +65,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = EVENT_FINDER_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
@@ -82,8 +83,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
),
),
)
}
.flatten()
}.flatten()
}
val userChannel =
@@ -109,8 +109,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
listOfNotNull(
createUserMetadataFilter(),
createUserMetadataStatusReportFilter(),
)
.flatten()
).flatten()
.ifEmpty { null }
}
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
@@ -79,6 +80,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
PinListEvent.KIND,
PollNoteEvent.KIND,
HighlightEvent.KIND,
WikiNoteEvent.KIND,
),
authors = listOf(it.pubkeyHex),
limit = 200,
@@ -94,6 +96,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
JsonFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
),
)
}
@@ -176,7 +179,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
createAcceptedAwardsFilter(),
createReceivedAwardsFilter(),
createBookmarksFilter(),
)
.ifEmpty { null }
).ifEmpty { null }
}
}
@@ -28,10 +28,15 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
@@ -40,8 +45,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
var job: Job? = null
val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav")
override fun start() {
job?.cancel()
job =
@@ -68,9 +71,9 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
filter =
JsonFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES),
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -89,14 +92,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"t" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
@@ -117,14 +120,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
types = setOf(FeedType.GLOBAL),
filter =
JsonFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND),
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
"g" to
hashToLoad
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
.flatten(),
"m" to SUPPORTED_VIDEO_MIME_TYPES,
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
),
limit = 100,
since =
@@ -28,6 +28,10 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
@@ -35,11 +39,12 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
class ZapPaymentHandler(val account: Account) {
class ZapPaymentHandler(
val account: Account,
) {
@Immutable
data class Payable(
val info: ZapSplitSetup,
@@ -54,27 +59,48 @@ class ZapPaymentHandler(val account: Account) {
pollOption: Int?,
message: String,
context: Context,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
zapType: LnZapEvent.ZapType,
) = withContext(Dispatchers.IO) {
val zapSplitSetup = note.event?.zapSplitSetup()
val noteEvent = note.event
val zapSplitSetup = noteEvent?.zapSplitSetup()
val zapsToSend =
if (!zapSplitSetup.isNullOrEmpty()) {
zapSplitSetup
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
} else if (noteEvent is AppDefinitionEvent) {
val appLud16 = noteEvent.appMetaData()?.lnAddress()
if (appLud16 != null) {
listOf(ZapSplitSetup(appLud16, null, weight = 1.0, true))
} else {
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
return@withContext
}
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
} else {
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
val lud16 = note.author?.info?.lnAddress()
if (lud16.isNullOrBlank()) {
onError(
context.getString(R.string.missing_lud16),
context.getString(
stringRes(context, R.string.missing_lud16),
stringRes(
context,
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
),
)
@@ -84,100 +110,256 @@ class ZapPaymentHandler(val account: Account) {
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
}
val totalWeight = zapsToSend.sumOf { it.weight }
val invoicesToPayOnIntent = mutableListOf<Payable>()
zapsToSend.forEachIndexed { index, value ->
val outerProgressMin = index / zapsToSend.size.toFloat()
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
if (value.isLnAddress) {
innerZap(
lud16 = value.lnAddressOrPubKeyHex,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = null,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
if (splitZapRequestPairs.isEmpty()) {
onProgress(0.00f)
return@signAllZapRequests
} else {
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
val lud16 = user?.info?.lnAddress()
onProgress(0.05f)
}
if (lud16 != null) {
innerZap(
lud16 = lud16,
note = note,
amount = zapValue,
pollOption = pollOption,
message = message,
context = context,
onError = onError,
onProgress = {
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
},
zapType = zapType,
overrideUser = user,
onPayInvoiceThroughIntent = {
invoicesToPayOnIntent.add(
Payable(
info = value,
user = user,
amountMilliSats = zapValue,
invoice = it,
),
)
},
)
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
if (it.isEmpty()) {
onProgress(0.00f)
return@assembleAllInvoices
} else {
onError(
context.getString(
R.string.missing_lud16,
),
context.getString(
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
),
onProgress(0.75f)
}
if (account.hasWalletConnectSetup()) {
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
onProgress(it * 0.25f + 0.75f) // keeps within range.
}, context) {
// onProgress(1f)
}
} else {
onPayViaIntent(
it
.map {
Payable(
info = it.key.first,
user = it.key.second.user,
amountMilliSats = it.value.zapValue,
invoice = it.value.invoice,
)
}.toImmutableList(),
)
onProgress(0f)
}
}
}
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
var count = 0
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
count++
Thread.sleep(5000)
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
private fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
totalWeight: Double,
): Long {
val shareValue = amountMilliSats * (weight / totalWeight)
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
return roundedZapValue
}
class SignAllZapRequestsReturn(
val zapRequestJson: String,
val user: User? = null,
)
suspend fun signAllZapRequests(
note: Note,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<ZapSplitSetup>,
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
) {
val authorRelayList =
note.author
?.pubkeyHex
?.let {
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(it),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()
}?.toSet()
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
operationsInput = zapsToSend,
runRequestFor = { next: ZapSplitSetup, onReady ->
if (next.isLnAddress) {
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson))
}
}
} else {
onProgress(1f)
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
val userRelayList =
(
(
LocalCache
.getAddressableNoteIfExists(
AdvertisedRelayListEvent.createAddressTag(next.lnAddressOrPubKeyHex),
)?.event as? AdvertisedRelayListEvent?
)?.readRelays()?.toSet() ?: emptySet()
) + (authorRelayList ?: emptySet())
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
if (zapRequestJson != null) {
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
}
}
},
onReady = onAllDone,
)
}
suspend fun assembleAllInvoices(
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
totalAmountMilliSats: Long,
message: String,
showErrorIfNoLnAddress: Boolean,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
) {
var progressAllPayments = 0.00f
val totalWeight = invoices.sumOf { it.first.weight }
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
operationsInput = invoices,
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
assembleInvoice(
splitSetup = splitZapRequestPair.first,
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
message = message,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
onError = onError,
onProgressStep = { percentStepForThisPayment ->
progressAllPayments += percentStepForThisPayment / invoices.size
onProgress(progressAllPayments)
},
context = context,
onReady = onReady,
)
},
onReady = onAllDone,
)
}
suspend fun payViaNWC(
invoices: List<String>,
note: Note,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
context: Context,
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
) {
var progressAllPayments = 0.00f
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
runRequestFor = { invoice: String, onReady ->
account.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = note,
onSent = {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onReady(true)
},
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
}
},
)
},
onReady = onAllDone,
)
}
class AssembleInvoiceReturn(
val zapValue: Long,
val invoice: String,
)
private fun assembleInvoice(
splitSetup: ZapSplitSetup,
nostrZapRequest: String,
zapValue: Long,
message: String,
showErrorIfNoLnAddress: Boolean = true,
onError: (String, String) -> Unit,
onProgressStep: (percent: Float) -> Unit,
context: Context,
onReady: (AssembleInvoiceReturn) -> Unit,
) {
var progressThisPayment = 0.00f
var user: User? = null
val lud16 =
if (splitSetup.isLnAddress) {
splitSetup.lnAddressOrPubKeyHex
} else {
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
user?.info?.lnAddress()
}
if (lud16 != null) {
LightningAddressResolver()
.lnAddressInvoice(
lnaddress = lud16,
milliSats = zapValue,
message = message,
nostrRequest = nostrZapRequest,
onError = onError,
onProgress = {
val step = it - progressThisPayment
progressThisPayment = it
onProgressStep(step)
},
context = context,
onSuccess = {
onProgressStep(1 - progressThisPayment)
onReady(AssembleInvoiceReturn(zapValue, it))
},
)
} else {
if (showErrorIfNoLnAddress) {
onError(
stringRes(
context,
R.string.missing_lud16,
),
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
),
)
}
}
}
@@ -188,73 +370,16 @@ class ZapPaymentHandler(val account: Account) {
message: String,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
additionalRelays: Set<String>? = null,
onReady: (String?) -> Unit,
) {
if (zapType != LnZapEvent.ZapType.NONZAP) {
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
println("Zap Request " + zapRequest.toJson())
onReady(zapRequest.toJson())
}
} else {
onReady(null)
}
}
private suspend fun innerZap(
lud16: String,
note: Note,
amount: Long,
pollOption: Int?,
message: String,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayInvoiceThroughIntent: (String) -> Unit,
zapType: LnZapEvent.ZapType,
overrideUser: User? = null,
) {
onProgress(0.05f)
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
onProgress(0.10f)
LightningAddressResolver()
.lnAddressInvoice(
lud16,
amount,
message,
zapRequestJson,
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
onError(
context.getString(R.string.error_dialog_pay_invoice_error),
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
),
)
} else {
onProgress(1f)
}
},
)
onProgress(0.8f)
} else {
onPayInvoiceThroughIntent(it)
onProgress(0f)
}
},
onError = onError,
onProgress = onProgress,
context = context,
)
}
}
}
@@ -26,16 +26,16 @@ import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.toLnUrl
import okhttp3.Request
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import kotlin.coroutines.cancellation.CancellationException
class LightningAddressResolver() {
class LightningAddressResolver {
val client = HttpClientManager.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
@@ -64,8 +64,9 @@ class LightningAddressResolver() {
if (url == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
lnaddress,
),
@@ -75,7 +76,8 @@ class LightningAddressResolver() {
try {
val request: Request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -85,8 +87,9 @@ class LightningAddressResolver() {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
@@ -100,8 +103,9 @@ class LightningAddressResolver() {
if (e is CancellationException) throw e
e.printStackTrace()
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
@@ -134,7 +138,8 @@ class LightningAddressResolver() {
}
val request: Request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
@@ -144,27 +149,13 @@ class LightningAddressResolver() {
onSuccess(it.body.string())
} else {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(R.string.could_not_fetch_invoice_from, lnCallback),
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
)
}
}
}
fun lnAddressToLnUrl(
lnaddress: String,
onSuccess: (String) -> Unit,
onError: (String, String) -> Unit,
context: Context,
) {
fetchLightningAddressJson(
lnaddress,
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
onError = onError,
context = context,
)
}
fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
@@ -188,9 +179,11 @@ class LightningAddressResolver() {
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -200,9 +193,11 @@ class LightningAddressResolver() {
if (callback == null) {
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnaddress,
),
)
}
@@ -224,10 +219,12 @@ class LightningAddressResolver() {
} catch (t: Throwable) {
if (t is CancellationException) throw t
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnaddress,
),
)
null
@@ -248,8 +245,9 @@ class LightningAddressResolver() {
} else {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnaddress,
@@ -265,10 +263,12 @@ class LightningAddressResolver() {
?.let { reason ->
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnaddress,
reason,
),
)
@@ -276,10 +276,12 @@ class LightningAddressResolver() {
?: run {
onProgress(0.0f)
onError(
context.getString(R.string.error_unable_to_fetch_invoice),
context.getString(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnaddress,
),
)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
@@ -43,8 +45,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) {
class EventNotificationConsumer(
private val applicationContext: Context,
) {
suspend fun consume(event: GiftWrapEvent) {
Log.d("EventNotificationConsumer", "New Notification Arrived")
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
@@ -64,14 +69,23 @@ class EventNotificationConsumer(private val applicationContext: Context) {
account: Account,
) {
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
if (!LocalCache.hasConsumed(notificationEvent) && LocalCache.justVerify(notificationEvent)) {
val consumed = LocalCache.hasConsumed(notificationEvent)
val verified = LocalCache.justVerify(notificationEvent)
Log.d("EventNotificationConsumer", "New Notification Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
if (!consumed && verified) {
Log.d("EventNotificationConsumer", "New Notification was verified")
unwrapAndConsume(notificationEvent, account) { innerEvent ->
if (!LocalCache.hasConsumed(innerEvent)) {
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
if (!consumed) {
if (innerEvent is PrivateDmEvent) {
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
Log.d("EventNotificationConsumer", "New Zap to Notify")
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
notify(innerEvent, account)
}
}
@@ -86,6 +100,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
onReady: (Event) -> Unit,
) {
if (!LocalCache.justVerify(event)) return
if (LocalCache.hasConsumed(event)) return
when (event) {
is GiftWrapEvent -> {
@@ -93,9 +108,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
is SealedGossipEvent -> {
event.cachedGossip(account.signer) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
if (!LocalCache.hasConsumed(it)) {
// this is not verifiable
LocalCache.justConsume(it, null)
onReady(it)
}
}
}
else -> {
@@ -110,7 +127,8 @@ class EventNotificationConsumer(private val applicationContext: Context) {
acc: Account,
) {
if (
event.createdAt > TimeUtils.fifteenMinutesAgo() && // old event being re-broadcasted
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
// old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
@@ -123,7 +141,8 @@ class EventNotificationConsumer(private val applicationContext: Context) {
(
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
) &&
!acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
@@ -164,9 +183,9 @@ class EventNotificationConsumer(private val applicationContext: Context) {
(
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}
.toSet()
) &&
!acc.isAllHidden(it.users)
}.toSet()
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
@@ -209,17 +228,19 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = senderInfo.first.toBestDisplayName()
var title =
applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
var content =
applicationContext.getString(
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_from,
user,
)
zappedContent?.let {
content +=
" " +
applicationContext.getString(
stringRes(
applicationContext,
R.string.app_notification_zaps_channel_message_for,
zappedContent,
)
@@ -241,8 +262,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
}
}
@@ -34,6 +34,7 @@ import coil.executeBlocking
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
@@ -46,14 +47,13 @@ object NotificationUtils {
dmChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_dms_channel_description)
}
).apply {
description =
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -69,14 +69,13 @@ object NotificationUtils {
zapChannel =
NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
stringRes(applicationContext, R.string.app_notification_zaps_channel_id),
stringRes(applicationContext, R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
.apply {
description =
applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
).apply {
description =
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
@@ -96,7 +95,7 @@ object NotificationUtils {
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
sendNotification(
id,
@@ -119,7 +118,7 @@ object NotificationUtils {
applicationContext: Context,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
sendNotification(
id,
@@ -205,13 +204,13 @@ object NotificationUtils {
// Build the notification
val builderPublic =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
@@ -222,11 +221,11 @@ object NotificationUtils {
// Build the notification
val builder =
NotificationCompat.Builder(
applicationContext,
channelId,
)
.setSmallIcon(R.drawable.amethyst)
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
@@ -30,14 +30,15 @@ import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import com.vitorpamplona.quartz.ots.http.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Class representing remote calendar server interface.
*/
class OkHttpCalendar(val url: String) : ICalendar {
class OkHttpCalendar(
val url: String,
) : ICalendar {
/**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
*
@@ -57,7 +58,8 @@ class OkHttpCalendar(val url: String) : ICalendar {
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -104,7 +106,8 @@ class OkHttpCalendar(val url: String) : ICalendar {
val url = url + "/timestamp/" + Hex.encode(commitment)
val request =
okhttp3.Request.Builder()
okhttp3.Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -86,6 +86,8 @@ class MultiPlayerPlaybackManager(
applicationContext: Context,
): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id)
// avoids saving positions for live streams otherwise caching goes crazy
val mustCachePositions = !uri.contains(".m3u8", true)
if (existingSession != null) return existingSession
val player =
@@ -115,7 +117,9 @@ class MultiPlayerPlaybackManager(
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cachedPositions.add(uri, player.currentPosition)
if (mustCachePositions) {
cachedPositions.add(uri, player.currentPosition)
}
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
@@ -125,20 +129,22 @@ class MultiPlayerPlaybackManager(
when (playbackState) {
STATE_IDLE -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
STATE_READY -> {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
if (mustCachePositions) {
cachedPositions.get(uri)?.let { lastPosition ->
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
player.seekTo(lastPosition)
}
}
}
}
else -> {
// only saves if it wqs playing
if (abs(player.currentPosition) > 1) {
if (mustCachePositions && abs(player.currentPosition) > 1) {
cachedPositions.add(uri, player.currentPosition)
}
}
@@ -150,7 +156,9 @@ class MultiPlayerPlaybackManager(
newPosition: PositionInfo,
reason: Int,
) {
cachedPositions.add(uri, newPosition.positionMs)
if (mustCachePositions && player.playbackState != STATE_IDLE) {
cachedPositions.add(uri, newPosition.positionMs)
}
}
},
)
@@ -27,13 +27,13 @@ import android.util.Log
import android.util.LruCache
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CancellationException
import java.util.concurrent.Executors
object PlaybackClientController {
var executorService = Executors.newCachedThreadPool()
val cache = LruCache<Int, SessionToken>(1)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController(
controllerID: String,
videoUri: String,
@@ -67,7 +67,7 @@ object PlaybackClientController {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor(),
executorService,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -32,55 +32,53 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HttpClientManager
import okhttp3.OkHttpClient
class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
@UnstableApi
val http = DefaultMediaSourceFactory(OkHttpDataSource.Factory(httpClient))
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory() : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
@UnstableApi
val wss = DefaultMediaSourceFactory(WssStreamDataSource.Factory(httpClient))
@OptIn(UnstableApi::class)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
http.setDrmSessionManagerProvider(drmSessionManagerProvider)
wss.setDrmSessionManagerProvider(drmSessionManagerProvider)
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
@OptIn(UnstableApi::class)
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
http.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
wss.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
@OptIn(UnstableApi::class)
override fun getSupportedTypes(): IntArray {
return http.supportedTypes
return nonCachingFactory.supportedTypes
}
@OptIn(UnstableApi::class)
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
return if (mediaItem.mediaId.startsWith("wss")) {
wss.createMediaSource(mediaItem)
} else {
http.createMediaSource(mediaItem)
if (mediaItem.mediaId.contains(".m3u8", true)) {
return nonCachingFactory.createMediaSource(mediaItem)
}
return cachingFactory.createMediaSource(mediaItem)
}
}
@UnstableApi // Extend MediaSessionService
class PlaybackService : MediaSessionService() {
private var videoViewedPositionCache = VideoViewedPositionCache()
private var managerAllInOne: MultiPlayerPlaybackManager? = null
@OptIn(UnstableApi::class)
fun newAllInOneDataSource(): MediaSource.Factory {
// This might be needed for live kit.
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
return DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
return CustomMediaSourceFactory()
}
fun lazyDS(): MultiPlayerPlaybackManager {
@@ -27,6 +27,8 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
@@ -41,16 +43,17 @@ class VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun initFileCache(context: Context) {
suspend fun initFileCache(context: Context) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
withContext(Dispatchers.IO) {
simpleCache =
SimpleCache(
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider,
)
}
}
// This method should be called when proxy setting changes.
@@ -21,13 +21,13 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
@@ -42,10 +42,10 @@ object Client : RelayPool.Listener {
@Synchronized
fun reconnect(
relays: Array<Relay>?,
relays: Array<RelaySetupInfo>?,
onlyIfChanged: Boolean = false,
) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}")
checkNotInMainThread()
if (onlyIfChanged) {
@@ -57,10 +57,11 @@ object Client : RelayPool.Listener {
}
if (relays != null) {
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.loadRelays(newRelays)
RelayPool.requestAndWatch()
this.relays = relays
this.relays = newRelays.toTypedArray()
}
}
} else {
@@ -71,15 +72,16 @@ object Client : RelayPool.Listener {
}
if (relays != null) {
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.loadRelays(newRelays)
RelayPool.requestAndWatch()
this.relays = relays
this.relays = newRelays.toTypedArray()
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
fun isSameRelaySetConfig(newRelayConfig: Array<RelaySetupInfo>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
@@ -101,6 +103,34 @@ object Client : RelayPool.Listener {
RelayPool.sendFilter(subscriptionId, filters)
}
fun sendFilterAndStopOnFirstResponse(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf(),
onResponse: (Event) -> Unit,
) {
checkNotInMainThread()
subscribe(
object : Listener() {
override fun onEvent(
event: Event,
subId: String,
relay: Relay,
afterEOSE: Boolean,
) {
if (subId == subscriptionId) {
onResponse(event)
unsubscribe(this)
close(subscriptionId)
}
}
},
)
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId, filters)
}
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf(),
@@ -115,7 +145,7 @@ object Client : RelayPool.Listener {
signedEvent: EventInterface,
relay: String? = null,
feedTypes: Set<FeedType>? = null,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
onDone: (() -> Unit)? = null,
) {
checkNotInMainThread()
@@ -125,47 +155,21 @@ object Client : RelayPool.Listener {
} else if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
} else {
/** temporary connection */
newSporadicRelay(
relay,
feedTypes,
onConnected = { myRelay -> myRelay.send(signedEvent) },
onDone = onDone,
)
RelayPool.getOrCreateRelay(relay, feedTypes, onDone) {
it.send(signedEvent)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
fun sendPrivately(
signedEvent: EventInterface,
relayList: List<String>,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
checkNotInMainThread()
relay.connectAndRun {
allSubscriptions().forEach {
relay.sendFilter(it.key, it.value)
}
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
if (onDone != null) {
onDone()
}
relayList.forEach { relayUrl ->
RelayPool.getOrCreateRelay(relayUrl, emptySet(), { }) {
it.sendOverride(signedEvent)
}
}
}
@@ -193,19 +197,6 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange(
type: Relay.StateType,
@@ -283,13 +274,6 @@ object Client : RelayPool.Listener {
afterEOSE: Boolean,
) = Unit
/** A new or repeat message was received */
open fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
) = Unit
/** Connected to or disconnected from a relay */
open fun onRelayStateChange(
type: Relay.StateType,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -36,28 +37,26 @@ object Constants {
val defaultRelays =
arrayOf(
// Free relays for only DMs, Chats and Follows due to the amount of spam
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypes),
// Global
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats),
// Paid relays
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch),
)
val forcedRelayForSearch =
arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
val defaultSearchRelaySet =
setOf(
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
RelayUrlFormatter.normalize("wss://nostr.wine"),
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
}
@@ -23,14 +23,15 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import kotlinx.coroutines.CancellationException
import okhttp3.Request
import okhttp3.Response
@@ -50,38 +51,33 @@ enum class FeedType {
val COMMON_FEED_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
val EVENT_FINDER_TYPES =
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL)
class Relay(
val url: String,
val read: Boolean = true,
val write: Boolean = true,
val activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
val brief = RelayBriefInfoCache.get(url)
companion object {
// waits 3 minutes to reconnect once things fail
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = HttpClientManager.getHttpClient()
val brief = RelayBriefInfoCache.get(url)
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
private var usingCompression: Boolean = false
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
private var lastConnectTentative: Long = 0L
var spamCounter = 0
var errorCounter = 0
var pingInMs: Long? = null
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
var lastConnectTentative: Long = 0L
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
val authResponse = mutableMapOf<HexKey, Boolean>()
private val authResponse = mutableMapOf<HexKey, Boolean>()
private val sendWhenReady = mutableListOf<EventInterface>()
fun register(listener: Listener) {
listeners = listeners.plus(listener)
@@ -91,9 +87,7 @@ class Relay(
listeners = listeners.minus(listener)
}
fun isConnected(): Boolean {
return socket != null
}
fun isConnected(): Boolean = socket != null
fun connect() {
connectAndRun {
@@ -107,7 +101,7 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingBlock.get()}")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -116,33 +110,39 @@ class Relay(
return
}
checkNotInMainThread()
if (socket != null) return
lastConnectTentative = TimeUtils.now()
try {
checkNotInMainThread()
if (socket != null) {
connectingBlock.set(false)
return
}
lastConnectTentative = TimeUtils.now()
val request =
Request.Builder()
Request
.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
socket = HttpClientManager.getHttpClientForUrl(url).newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
if (e is CancellationException) throw e
errorCounter++
RelayStats.newError(url, e.message)
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
} finally {
connectingBlock.set(false)
}
}
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
inner class RelayListener(
val onConnected: (Relay) -> Unit,
) : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
@@ -159,6 +159,13 @@ class Relay(
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
synchronized(sendWhenReady) {
sendWhenReady.forEach {
send(it)
}
sendWhenReady.clear()
}
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
}
@@ -168,7 +175,7 @@ class Relay(
) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
RelayStats.addBytesReceived(url, text.bytesUsedInMemory())
try {
processNewRelayMessage(text)
@@ -220,21 +227,25 @@ class Relay(
) {
checkNotInMainThread()
errorCounter++
socket?.cancel() // 1000, "Normal close"
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
RelayStats.newError(url, response?.message ?: t.message)
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response ${t.message} $socket")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
// Failures disconnect the relay.
markConnectionAsClosed()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(
this@Relay,
"",
Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t),
)
}
}
}
@@ -244,8 +255,9 @@ class Relay(
) {
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
RelayStats.setPing(url, pingInMs)
}
fun markConnectionAsClosed() {
@@ -264,6 +276,7 @@ class Relay(
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(
this@Relay,
@@ -286,6 +299,8 @@ class Relay(
val message = msgArray.get(1).asText()
Log.w("Relay", "Relay onNotice $url, $message")
RelayStats.newNotice(url, message)
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
}
"OK" ->
@@ -316,7 +331,9 @@ class Relay(
it.onNotify(this@Relay, msgArray[1].asText())
}
"CLOSED" -> listeners.forEach { Log.w("Relay", "Relay onClosed $url, $newMessage") }
else ->
else -> {
RelayStats.newError(url, "Unsupported message: $newMessage")
listeners.forEach {
Log.w("Relay", "Unsupported message: $newMessage")
it.onError(
@@ -325,6 +342,7 @@ class Relay(
Error("Unknown type $type on channel. Msg was $newMessage"),
)
}
}
}
}
@@ -369,11 +387,9 @@ class Relay(
it.filter.toJson(url)
}
// Log.d("Relay", "onFilterSent $url $requestId $request")
writeToSocket(request)
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
resetEOSEStatuses()
afterEOSEPerSubscription[requestId] = false
}
}
} else {
@@ -432,30 +448,36 @@ class Relay(
}
}
// This function sends the event regardless of the relay being write or not.
fun sendOverride(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
sendAuth(signedEvent)
} else {
sendEvent(signedEvent)
}
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
sendAuth(signedEvent)
} else {
if (write) {
val event = """["EVENT",${signedEvent.toJson()}]"""
if (isConnected()) {
if (isReady) {
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
} else {
synchronized(sendWhenReady) {
sendWhenReady.add(signedEvent)
}
}
} else {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
// Sends everything.
renewFilters()
@@ -465,20 +487,49 @@ class Relay(
}
}
fun close(subscriptionId: String) {
checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]"""
// Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg)
private fun sendAuth(signedEvent: RelayAuthEvent) {
authResponse.put(signedEvent.id, false)
writeToSocket("""["AUTH",${signedEvent.toJson()}]""")
}
fun isSameRelayConfig(other: Relay): Boolean {
return url == other.url &&
private fun sendEvent(signedEvent: EventInterface) {
if (isConnected()) {
if (isReady) {
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
}
} else {
// sends all filters after connection is successful.
connectAndRun {
writeToSocket("""["EVENT",${signedEvent.toJson()}]""")
// Sends everything.
renewFilters()
}
}
}
private fun writeToSocket(str: String) {
socket?.let {
checkNotInMainThread()
it.send(str)
RelayStats.addBytesSent(url, str.bytesUsedInMemory())
if (BuildConfig.DEBUG) {
Log.d("Relay", "Relay send $url $str")
}
}
}
fun close(subscriptionId: String) {
writeToSocket("""["CLOSE","$subscriptionId"]""")
}
fun isSameRelayConfig(other: RelaySetupInfo): Boolean =
url == other.url &&
write == other.write &&
read == other.read &&
activeTypes == other.activeTypes
}
activeTypes == other.feedTypes
enum class StateType {
// Websocket connected
@@ -21,13 +21,19 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
@@ -58,6 +64,58 @@ object RelayPool : Relay.Listener {
return relays.filter { it.url == url }
}
fun getOrCreateRelay(
url: String,
feedTypes: Set<FeedType>? = null,
onDone: (() -> Unit)? = null,
whenConnected: (Relay) -> Unit,
) {
synchronized(this) {
val matching = getRelays(url)
if (matching.isNotEmpty()) {
matching.forEach { whenConnected(it) }
} else {
/** temporary connection */
newSporadicRelay(
url,
feedTypes,
onConnected = whenConnected,
onDone = onDone,
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(
url: String,
feedTypes: Set<FeedType>?,
onConnected: (Relay) -> Unit,
onDone: (() -> Unit)?,
timeout: Long = 60000,
) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
addRelay(relay)
relay.connectAndRun {
Client.allSubscriptions().forEach {
relay.sendFilter(it.key, it.value)
}
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(timeout) // waits for a reply
relay.disconnect()
removeRelay(relay)
if (onDone != null) {
onDone()
}
}
}
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }
@@ -91,16 +149,20 @@ object RelayPool : Relay.Listener {
}
fun sendToSelectedRelays(
list: List<Relay>,
list: List<RelaySetupInfo>,
signedEvent: EventInterface,
) {
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } }
}
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}
fun sendOverride(signedEvent: EventInterface) {
relays.forEach { it.sendOverride(signedEvent) }
}
fun close(subscriptionId: String) {
relays.forEach { it.close(subscriptionId) }
}
@@ -137,12 +199,6 @@ object RelayPool : Relay.Listener {
afterEOSE: Boolean,
)
fun onError(
error: Error,
subscriptionId: String,
relay: Relay,
)
fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
@@ -181,7 +237,6 @@ object RelayPool : Relay.Listener {
subscriptionId: String,
error: Error,
) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
updateStatus()
}
@@ -0,0 +1,138 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relays
import androidx.collection.LruCache
import com.vitorpamplona.quartz.utils.TimeUtils
object RelayStats {
private val innerCache = mutableMapOf<String, RelayStat>()
fun get(url: String): RelayStat {
return innerCache.getOrPut(url) { RelayStat() }
}
fun addBytesReceived(
url: String,
bytesUsedInMemory: Int,
) {
get(url).addBytesReceived(bytesUsedInMemory)
}
fun addBytesSent(
url: String,
bytesUsedInMemory: Int,
) {
get(url).addBytesSent(bytesUsedInMemory)
}
fun newError(
url: String,
error: String?,
) {
get(url).newError(error)
}
fun newNotice(
url: String,
notice: String?,
) {
get(url).newNotice(notice)
}
fun setPing(
url: String,
pingInMs: Long,
) {
get(url).pingInMs = pingInMs
}
fun newSpam(
url: String,
explanation: String,
) {
get(url).newSpam(explanation)
}
}
class RelayStat(
var receivedBytes: Long = 0L,
var sentBytes: Long = 0L,
var spamCounter: Long = 0L,
var errorCounter: Long = 0L,
var pingInMs: Long = 0L,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
fun newNotice(notice: String?) {
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.NOTICE,
message = notice ?: "No error message provided",
)
messages.put(debugMessage, debugMessage)
}
fun newError(error: String?) {
errorCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.ERROR,
message = error ?: "No error message provided",
)
messages.put(debugMessage, debugMessage)
}
fun addBytesReceived(bytesUsedInMemory: Int) {
receivedBytes += bytesUsedInMemory
}
fun addBytesSent(bytesUsedInMemory: Int) {
sentBytes += bytesUsedInMemory
}
fun newSpam(spamDescriptor: String) {
spamCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
messages.put(debugMessage, debugMessage)
}
}
class RelayDebugMessage(
val type: RelayDebugMessageType,
val message: String,
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
}
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(act.isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
}
return sharedPreferencesViewModel
}
@Composable
fun AppScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
serviceManager: ServiceManager,
) {
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
@@ -33,16 +33,7 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
@@ -53,10 +44,6 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.events.ChannelCreateEvent
@@ -76,14 +63,13 @@ import java.util.Timer
import kotlin.concurrent.schedule
class MainActivity : AppCompatActivity() {
private val isOnMobileDataState = mutableStateOf(false)
val isOnMobileDataState = mutableStateOf(false)
private val isOnWifiDataState = mutableStateOf(false)
// Service Manager is only active when the activity is active.
val serviceManager = ServiceManager()
private var shouldPauseService = true
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -91,36 +77,8 @@ class MainActivity : AppCompatActivity() {
Log.d("Lifetime Event", "MainActivity.onCreate")
setContent {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(this)
val windowSizeClass = calculateWindowSizeClass(this)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
LaunchedEffect(isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
}
AmethystTheme(sharedPreferencesViewModel) {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val accountStateViewModel: AccountStateViewModel = viewModel()
accountStateViewModel.serviceManager = serviceManager
LaunchedEffect(key1 = Unit) {
accountStateViewModel.tryLoginExistingAccountAsync()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
}
}
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
}
}
@@ -132,6 +90,11 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
val locales = this.applicationContext.resources.configuration.locales
if (!locales.isEmpty) {
checkLanguage(locales.get(0).language)
}
Log.d("Lifetime Event", "MainActivity.onResume")
// starts muted every time
@@ -305,8 +268,8 @@ class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
}
}
fun uriToRoute(uri: String?): String? {
return if (uri.equals("nostr:Notifications", true)) {
fun uriToRoute(uri: String?): String? =
if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
@@ -359,4 +322,3 @@ fun uriToRoute(uri: String?): String? {
null
}
}
}
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
/**
* Cache for stringResource because it seems to be > 1ms function in some phones
*/
private val resourceCache = LruCache<Int, String>(300)
private var resourceCacheLanguage: String? = null
fun checkLanguage(currentLanguage: String) {
if (resourceCacheLanguage == null) {
resourceCacheLanguage = currentLanguage
} else {
if (resourceCacheLanguage != currentLanguage) {
resourceCacheLanguage = currentLanguage
resourceCache.evictAll()
}
}
}
@Composable
fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) }
@Composable
fun stringRes(
id: Int,
vararg args: Any,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id),
*args,
).also { resourceCache.put(id, it) }
fun stringRes(
ctx: Context,
id: Int,
): String = resourceCache.get(id) ?: ctx.getString(id).also { resourceCache.put(id, it) }
fun stringRes(
ctx: Context,
id: Int,
vararg args: Any?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id),
*args,
).also { resourceCache.put(id, it) }
}
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun <T> CrossfadeIfEnabled(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
Box(modifier) {
content(targetState)
}
} else {
Crossfade(targetState, modifier, animationSpec, label, content)
}
}
@@ -78,7 +78,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
@@ -98,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@@ -189,7 +189,7 @@ fun EditPostView(
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = stringResource(id = R.string.relay_list_selector),
contentDescription = stringRes(id = R.string.relay_list_selector),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
@@ -236,8 +236,7 @@ fun EditPostView(
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding(),
)
.fillMaxSize(),
).fillMaxSize(),
) {
Column(
modifier =
@@ -304,11 +303,13 @@ fun EditPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
@@ -323,7 +324,7 @@ fun EditPostView(
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
}
}
}
@@ -363,8 +364,8 @@ fun EditPostView(
lud16,
user.pubkeyHex,
accountViewModel.account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
@@ -384,7 +385,7 @@ fun EditPostView(
) {
Column {
Text(
text = stringResource(R.string.message_to_author),
text = stringRes(R.string.message_to_author),
fontSize = 18.sp,
fontWeight = FontWeight.W500,
)
@@ -397,7 +398,7 @@ fun EditPostView(
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.message_to_author_placeholder),
text = stringRes(R.string.message_to_author_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -512,8 +513,7 @@ private fun MessageField(postViewModel: EditPostViewModel) {
width = 1.dp,
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
)
.focusRequester(focusRequester)
).focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
@@ -521,7 +521,7 @@ private fun MessageField(postViewModel: EditPostViewModel) {
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
text = stringRes(R.string.what_s_on_your_mind),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -546,14 +546,14 @@ private fun AddLnInvoiceButton(
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
contentDescription = stringRes(id = R.string.add_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
contentDescription = stringRes(id = R.string.cancel_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
@@ -36,11 +36,11 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.FileHeaderEvent
@@ -109,11 +109,11 @@ open class EditPostViewModel() : ViewModel() {
editedFromNote = edit
}
fun sendPost(relayList: List<Relay>? = null) {
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
suspend fun innerSendPost(relayList: List<RelaySetupInfo>? = null) {
if (accountViewModel == null) {
cancel()
return
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.service.HttpClientManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
@@ -36,7 +37,12 @@ class ImageDownloader {
try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
var huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
@@ -45,7 +51,12 @@ class ImageDownloader {
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
huc =
if (HttpClientManager.getDefaultProxy() != null) {
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
} else {
url.openConnection() as HttpURLConnection
}
responseCode = huc.responseCode
}
@@ -29,6 +29,8 @@ import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import kotlinx.coroutines.CancellationException
@@ -45,6 +47,33 @@ import java.io.File
import java.util.UUID
object ImageSaver {
fun saveImage(
videoUri: String?,
mimeType: String?,
localContext: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
if (videoUri != null) {
if (!videoUri.startsWith("file")) {
saveImage(
context = localContext,
url = videoUri,
onSuccess = onSuccess,
onError = onError,
)
} else {
saveImage(
context = localContext,
localFile = videoUri.toUri().toFile(),
mimeType = mimeType,
onSuccess = onSuccess,
onError = onError,
)
}
}
}
/**
* Saves the image to the gallery. May require a storage permission.
*
@@ -34,8 +34,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -64,7 +64,7 @@ fun InformationDialog(
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringResource(R.string.error_dialog_button_ok))
Text(stringRes(R.string.error_dialog_button_ok))
}
}
},
@@ -59,10 +59,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -73,6 +71,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -83,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -161,7 +161,7 @@ fun JoinUserOrChannelView(
)
Text(
text = stringResource(R.string.channel_list_join_conversation),
text = stringRes(R.string.channel_list_join_conversation),
fontWeight = FontWeight.Bold,
)
@@ -278,13 +278,13 @@ private fun SearchEditTextForJoin(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
label = { Text(text = stringRes(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) { searchTextChanges.trySend(it) }
},
leadingIcon = { SearchIcon(modifier = Size20Modifier, Color.Unspecified) },
leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) },
modifier =
Modifier
.weight(1f, true)
@@ -297,7 +297,7 @@ private fun SearchEditTextForJoin(
},
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
text = stringRes(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -311,7 +311,7 @@ private fun SearchEditTextForJoin(
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear),
contentDescription = stringRes(R.string.clear),
)
}
}
@@ -331,11 +331,6 @@ private fun RenderSearchResults(
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
Row(
modifier =
Modifier
@@ -367,7 +362,11 @@ private fun RenderSearchResults(
channels,
key = { _, item -> "c" + item.idHex },
) { _, item ->
RenderChannel(item, automaticallyShowProfilePicture) {
RenderChannel(
item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
@@ -385,6 +384,7 @@ private fun RenderSearchResults(
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
onClick: () -> Unit,
) {
val hasNewMessages = remember { mutableStateOf(false) }
@@ -403,6 +403,7 @@ private fun RenderChannel(
hasNewMessages,
onClick = onClick,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
)
}
@@ -414,14 +415,15 @@ fun UserComposeForChat(
) {
Row(
modifier =
Modifier.clickable(
onClick = onClick,
).padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
Modifier
.clickable(
onClick = onClick,
).padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
@@ -432,7 +434,7 @@ fun UserComposeForChat(
.padding(start = 10.dp)
.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) }
Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) }
DisplayUserAboutInfo(baseUser)
}
@@ -38,7 +38,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
@@ -48,6 +47,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@@ -87,20 +87,21 @@ fun NewChannelView(
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank(),
postViewModel.channelName.value.text
.isNotBlank(),
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_name)) },
label = { Text(text = stringRes(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_awesome_group),
text = stringRes(R.string.my_awesome_group),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -114,7 +115,7 @@ fun NewChannelView(
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.picture_url)) },
label = { Text(text = stringRes(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
@@ -129,13 +130,13 @@ fun NewChannelView(
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.description)) },
label = { Text(text = stringRes(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = stringResource(R.string.about_us),
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -31,11 +31,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -80,7 +81,7 @@ open class NewMediaModel : ViewModel() {
fun upload(
context: Context,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
) {
isUploadingImage = true
@@ -114,7 +115,7 @@ open class NewMediaModel : ViewModel() {
}
?: run {
viewModelScope.launch {
onError(context.getString(R.string.could_not_open_the_compressed_file))
onError(stringRes(context, R.string.could_not_open_the_compressed_file))
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
@@ -153,7 +154,7 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(context.getString(R.string.failed_to_upload_media, e.message))
onError(stringRes(context, R.string.failed_to_upload_media, e.message))
}
}
}
@@ -162,7 +163,7 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(context.getString(R.string.error_when_compressing_media, it))
onError(stringRes(context, R.string.error_when_compressing_media, it))
},
)
}
@@ -179,16 +180,14 @@ open class NewMediaModel : ViewModel() {
selectedServer = ServerOption(defaultServer(), false)
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun canPost(): Boolean = !isUploadingImage && galleryUri != null && selectedServer != null
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
context: Context,
) {
uploadingPercentage.value = 0.40f
@@ -197,11 +196,20 @@ open class NewMediaModel : ViewModel() {
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
@@ -214,7 +222,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading))
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
return
}
@@ -254,7 +262,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
},
)
} else {
@@ -263,7 +271,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.could_not_download_from_the_server))
onError(stringRes(context, R.string.could_not_download_from_the_server))
}
}
@@ -272,7 +280,7 @@ open class NewMediaModel : ViewModel() {
mimeType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
context: Context,
) {
if (bytes.size > 80000) {
@@ -312,7 +320,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
onError(stringRes(context, R.string.could_not_prepare_local_file_to_upload, it))
},
)
}
@@ -58,7 +58,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -70,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
@@ -93,7 +93,7 @@ fun NewMediaView(
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType) {
accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it)
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
}
}
@@ -124,7 +124,8 @@ fun NewMediaView(
Column(
modifier =
Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.fillMaxWidth()
.fillMaxHeight()
.imePadding(),
@@ -194,7 +195,7 @@ fun ImageVideoPost(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringResource(id = R.string.upload_server_relays_nip95),
stringRes(id = R.string.upload_server_relays_nip95),
),
true,
),
@@ -209,7 +210,8 @@ fun ImageVideoPost(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
@@ -219,7 +221,8 @@ fun ImageVideoPost(
contentDescription = postViewModel.galleryUri.toString(),
contentScale = ContentScale.FillWidth,
modifier =
Modifier.padding(top = 4.dp)
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
@@ -251,7 +254,9 @@ fun ImageVideoPost(
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
mimeType = postViewModel.mediaType,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
@@ -263,7 +268,7 @@ fun ImageVideoPost(
modifier = Modifier.fillMaxWidth(),
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
@@ -294,13 +299,13 @@ fun ImageVideoPost(
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
label = { Text(text = stringRes(R.string.content_description)) },
modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
text = stringRes(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -146,9 +146,9 @@ class NewMessageTagger(
fun getNostrAddress(
bechAddress: String,
restOfTheWord: String,
restOfTheWord: String?,
): String {
return if (restOfTheWord.isEmpty()) {
return if (restOfTheWord.isNullOrEmpty()) {
"nostr:$bechAddress"
} else {
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
@@ -159,7 +159,7 @@ class NewMessageTagger(
}
}
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
@@ -181,7 +181,7 @@ class NewMessageTagger(
val pubkey =
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
} else if (key.startsWith("npub1", true)) {
if (key.length < 63) {
return null
@@ -192,7 +192,7 @@ class NewMessageTagger(
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
} else if (key.startsWith("note1", true)) {
if (key.length < 63) {
return null
@@ -203,7 +203,7 @@ class NewMessageTagger(
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
return DirtyKeyInfo(noteId, restOfWord.ifEmpty { null })
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
@@ -36,12 +36,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@@ -87,13 +87,13 @@ fun NewPollClosing(pollViewModel: NewPostViewModel) {
colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_closing_time),
text = stringRes(R.string.poll_closing_time),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_closing_time_days),
text = stringRes(R.string.poll_closing_time_days),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -36,12 +36,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CancellationException
@@ -87,13 +87,13 @@ fun NewPollConsensusThreshold(pollViewModel: NewPostViewModel) {
colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_consensus_threshold),
text = stringRes(R.string.poll_consensus_threshold),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_consensus_threshold_percent),
text = stringRes(R.string.poll_consensus_threshold_percent),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -31,10 +31,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@@ -51,7 +51,7 @@ fun NewPollOption(
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.clear),
contentDescription = stringRes(R.string.clear),
)
}
}
@@ -64,13 +64,13 @@ fun NewPollOption(
},
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),
text = stringRes(R.string.poll_option_index).format(optionIndex + 1),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_option_description),
text = stringRes(R.string.poll_option_description),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -37,13 +37,13 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalComposeUiApi::class)
@@ -74,7 +74,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
onValueChange = { pollViewModel.updateMessage(it) },
label = {
Text(
text = stringResource(R.string.poll_primary_description),
text = stringRes(R.string.poll_primary_description),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -90,7 +90,7 @@ fun NewPollPrimaryDescription(pollViewModel: NewPostViewModel) {
},
placeholder = {
Text(
text = stringResource(R.string.poll_primary_description),
text = stringRes(R.string.poll_primary_description),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -26,10 +26,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@@ -51,13 +51,13 @@ fun NewPollRecipientsField(
enabled = false,
label = {
Text(
text = stringResource(R.string.poll_zap_recipients),
text = stringRes(R.string.poll_zap_recipients),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.poll_zap_recipients),
text = stringRes(R.string.poll_zap_recipients),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -34,11 +34,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -67,13 +67,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_min),
text = stringRes(R.string.poll_zap_value_min),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
text = stringRes(R.string.sats),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -89,13 +89,13 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_value_max),
text = stringRes(R.string.poll_zap_value_max),
color = MaterialTheme.colorScheme.placeholderText,
)
},
placeholder = {
Text(
text = stringResource(R.string.sats),
text = stringRes(R.string.sats),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -107,7 +107,7 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.poll_zap_value_min_max_explainer),
text = stringRes(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
@@ -105,7 +105,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -150,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -276,7 +276,7 @@ fun NewPostView(
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = stringResource(id = R.string.relay_list_selector),
contentDescription = stringRes(id = R.string.relay_list_selector),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
@@ -324,8 +324,7 @@ fun NewPostView(
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding(),
)
.fillMaxSize(),
).fillMaxSize(),
) {
Column(
modifier =
@@ -425,11 +424,13 @@ fun NewPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
LoadUrlPreview(myUrlPreview, myUrlPreview, null, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
@@ -444,7 +445,7 @@ fun NewPostView(
nav = nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, null, accountViewModel)
}
}
}
@@ -509,8 +510,8 @@ fun NewPostView(
lud16,
accountViewModel.account.userProfile().pubkeyHex,
accountViewModel.account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
@@ -529,7 +530,7 @@ fun NewPostView(
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
stringRes(id = R.string.zapraiser),
postViewModel,
)
}
@@ -573,7 +574,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// val hashtag = stringRes(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
@@ -674,8 +675,7 @@ private fun MessageField(postViewModel: NewPostViewModel) {
width = 1.dp,
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
)
.focusRequester(focusRequester)
).focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
@@ -685,9 +685,9 @@ private fun MessageField(postViewModel: NewPostViewModel) {
Text(
text =
if (postViewModel.wantsProduct) {
stringResource(R.string.description)
stringRes(R.string.description)
} else {
stringResource(R.string.what_s_on_your_mind)
stringRes(R.string.what_s_on_your_mind)
},
color = MaterialTheme.colorScheme.placeholderText,
)
@@ -721,7 +721,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.content_warning),
contentDescription = stringRes(id = R.string.content_warning),
modifier =
Modifier
.size(18.dp)
@@ -730,7 +730,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.content_warning),
contentDescription = stringRes(id = R.string.content_warning),
modifier =
Modifier
.size(10.dp)
@@ -740,7 +740,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
}
Text(
text = stringResource(R.string.add_sensitive_content_label),
text = stringRes(R.string.add_sensitive_content_label),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
@@ -750,7 +750,7 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.add_sensitive_content_explainer),
text = stringRes(R.string.add_sensitive_content_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
@@ -767,7 +767,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.messages_new_message_to),
text = stringRes(R.string.messages_new_message_to),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -778,7 +778,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_to_caption),
text = stringRes(R.string.messages_new_message_to_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -801,7 +801,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.messages_new_message_subject),
text = stringRes(R.string.messages_new_message_subject),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -812,7 +812,7 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_subject_caption),
text = stringRes(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -842,7 +842,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.classifieds_title),
text = stringRes(R.string.classifieds_title),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -855,7 +855,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.classifieds_title_placeholder),
text = stringRes(R.string.classifieds_title_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -878,7 +878,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.classifieds_price),
text = stringRes(R.string.classifieds_price),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -915,7 +915,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.classifieds_condition),
text = stringRes(R.string.classifieds_condition),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -924,23 +924,23 @@ fun SellProduct(postViewModel: NewPostViewModel) {
listOf(
Triple(
ClassifiedsEvent.CONDITION.NEW,
stringResource(id = R.string.classifieds_condition_new),
stringResource(id = R.string.classifieds_condition_new_explainer),
stringRes(id = R.string.classifieds_condition_new),
stringRes(id = R.string.classifieds_condition_new_explainer),
),
Triple(
ClassifiedsEvent.CONDITION.USED_LIKE_NEW,
stringResource(id = R.string.classifieds_condition_like_new),
stringResource(id = R.string.classifieds_condition_like_new_explainer),
stringRes(id = R.string.classifieds_condition_like_new),
stringRes(id = R.string.classifieds_condition_like_new_explainer),
),
Triple(
ClassifiedsEvent.CONDITION.USED_GOOD,
stringResource(id = R.string.classifieds_condition_good),
stringResource(id = R.string.classifieds_condition_good_explainer),
stringRes(id = R.string.classifieds_condition_good),
stringRes(id = R.string.classifieds_condition_good_explainer),
),
Triple(
ClassifiedsEvent.CONDITION.USED_FAIR,
stringResource(id = R.string.classifieds_condition_fair),
stringResource(id = R.string.classifieds_condition_fair_explainer),
stringRes(id = R.string.classifieds_condition_fair),
stringRes(id = R.string.classifieds_condition_fair_explainer),
),
)
@@ -982,7 +982,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.classifieds_category),
text = stringRes(R.string.classifieds_category),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -1007,7 +1007,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
R.string.classifieds_category_other,
)
val categoryTypes = categoryList.map { Triple(it, stringResource(id = it), null) }
val categoryTypes = categoryList.map { Triple(it, stringRes(id = it), null) }
val categoryOptions =
remember {
@@ -1048,7 +1048,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.classifieds_location),
text = stringRes(R.string.classifieds_location),
fontSize = Font14SP,
fontWeight = FontWeight.W500,
)
@@ -1061,7 +1061,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.classifieds_location_placeholder),
text = stringRes(R.string.classifieds_location_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -1099,21 +1099,21 @@ fun FowardZapTo(
ZapSplitIcon()
Text(
text = stringResource(R.string.zap_split_title),
text = stringRes(R.string.zap_split_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(horizontal = 10.dp).weight(1f),
)
OutlinedButton(onClick = { postViewModel.updateZapFromText() }) {
Text(text = stringResource(R.string.load_from_text))
Text(text = stringRes(R.string.load_from_text))
}
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.zap_split_explainer),
text = stringRes(R.string.zap_split_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
@@ -1128,7 +1128,7 @@ fun FowardZapTo(
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
UsernameDisplay(splitItem.key)
UsernameDisplay(splitItem.key, accountViewModel = accountViewModel)
Text(
text = String.format("%.0f%%", splitItem.percentage * 100),
maxLines = 1,
@@ -1154,11 +1154,11 @@ fun FowardZapTo(
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = { postViewModel.updateZapForwardTo(it) },
label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) },
label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.zap_split_search_and_add_user_placeholder),
text = stringRes(R.string.zap_split_search_and_add_user_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -1205,7 +1205,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
}
Text(
text = stringResource(R.string.geohash_title),
text = stringRes(R.string.geohash_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
@@ -1217,7 +1217,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringResource(R.string.geohash_explainer),
text = stringRes(R.string.geohash_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
@@ -1275,7 +1275,7 @@ fun Notifying(
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
if (!mentions.isNullOrEmpty()) {
Text(
stringResource(R.string.reply_notify),
stringRes(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.align(CenterVertically),
@@ -1348,7 +1348,7 @@ private fun AddZapraiserButton(
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.add_zapraiser),
contentDescription = stringRes(R.string.add_zapraiser),
modifier =
Modifier
.size(13.dp)
@@ -1367,7 +1367,7 @@ private fun AddZapraiserButton(
)
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.cancel_zapraiser),
contentDescription = stringRes(R.string.cancel_zapraiser),
modifier =
Modifier
.size(13.dp)
@@ -1390,14 +1390,14 @@ fun AddGeoHash(
if (!postViewModel.wantsToAddGeoHash) {
Icon(
imageVector = Icons.Default.LocationOff,
contentDescription = stringResource(id = R.string.add_location),
contentDescription = stringRes(id = R.string.add_location),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = stringResource(id = R.string.remove_location),
contentDescription = stringRes(id = R.string.remove_location),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
@@ -1416,14 +1416,14 @@ private fun AddLnInvoiceButton(
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
contentDescription = stringRes(id = R.string.add_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
contentDescription = stringRes(id = R.string.cancel_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
@@ -1458,14 +1458,14 @@ private fun AddClassifiedsButton(
if (!postViewModel.wantsProduct) {
Icon(
imageVector = Icons.Default.Sell,
contentDescription = stringResource(R.string.classifieds),
contentDescription = stringRes(R.string.classifieds),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.Sell,
contentDescription = stringResource(id = R.string.cancel_classifieds),
contentDescription = stringRes(id = R.string.cancel_classifieds),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
@@ -1489,7 +1489,7 @@ private fun MarkAsSensitive(
if (!postViewModel.wantsToMarkAsSensitive) {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.add_content_warning),
contentDescription = stringRes(R.string.add_content_warning),
modifier =
Modifier
.size(18.dp)
@@ -1498,7 +1498,7 @@ private fun MarkAsSensitive(
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.add_content_warning),
contentDescription = stringRes(R.string.add_content_warning),
modifier =
Modifier
.size(10.dp)
@@ -1508,7 +1508,7 @@ private fun MarkAsSensitive(
} else {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.remove_content_warning),
contentDescription = stringRes(id = R.string.remove_content_warning),
modifier =
Modifier
.size(18.dp)
@@ -1517,7 +1517,7 @@ private fun MarkAsSensitive(
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.remove_content_warning),
contentDescription = stringRes(id = R.string.remove_content_warning),
modifier =
Modifier
.size(10.dp)
@@ -1550,7 +1550,7 @@ fun PostButton(
enabled = isActive,
onClick = onPost,
) {
Text(text = stringResource(R.string.post))
Text(text = stringRes(R.string.post))
}
}
@@ -1565,7 +1565,7 @@ fun SaveButton(
modifier = modifier,
onClick = onPost,
) {
Text(text = stringResource(R.string.save))
Text(text = stringRes(R.string.save))
}
}
@@ -1588,7 +1588,7 @@ fun CreateButton(
containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray,
),
) {
Text(text = stringResource(R.string.create), color = Color.White)
Text(text = stringRes(R.string.create), color = Color.White)
}
}
@@ -1613,7 +1613,7 @@ fun ImageVideoDescription(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringResource(id = R.string.upload_server_relays_nip95),
stringRes(id = R.string.upload_server_relays_nip95),
),
true,
),
@@ -1655,7 +1655,7 @@ fun ImageVideoDescription(
) {
Text(
text =
stringResource(
stringRes(
if (isImage) {
R.string.content_description_add_image
} else {
@@ -1736,7 +1736,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel)
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
}
}
@@ -1745,7 +1745,7 @@ fun ImageVideoDescription(
modifier = Modifier.fillMaxWidth(),
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
label = stringRes(id = R.string.file_server),
placeholder =
fileServers
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
@@ -1785,7 +1785,7 @@ fun ImageVideoDescription(
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
label = { Text(text = stringRes(R.string.content_description)) },
modifier =
Modifier
.fillMaxWidth()
@@ -1794,7 +1794,7 @@ fun ImageVideoDescription(
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
text = stringRes(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -1817,7 +1817,7 @@ fun ImageVideoDescription(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
Text(text = stringRes(R.string.add_content), color = Color.White, fontSize = 20.sp)
}
}
}
@@ -1851,12 +1851,12 @@ fun SettingSwitchItem(
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringResource(id = title),
text = stringRes(id = title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(id = description),
text = stringRes(id = description),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
@@ -40,12 +40,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -79,6 +79,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import kotlin.math.round
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@@ -92,10 +93,10 @@ open class NewPostViewModel() : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
var requiresNIP17: Boolean = false
var originalNote: Note? = null
var forkedFromNote: Note? = null
var originalNote: Note? by mutableStateOf<Note?>(null)
var forkedFromNote: Note? by mutableStateOf<Note?>(null)
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
@@ -169,8 +170,8 @@ open class NewPostViewModel() : ViewModel() {
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
val draftTextChanges = Channel<String>(Channel.CONFLATED)
@@ -425,8 +426,8 @@ open class NewPostViewModel() : ViewModel() {
TextFieldValue(draftEvent.content())
}
requiresNIP24 = draftEvent is ChatMessageEvent
nip24 = draftEvent is ChatMessageEvent
requiresNIP17 = draftEvent is ChatMessageEvent
nip17 = draftEvent is ChatMessageEvent
if (draftEvent is ChatMessageEvent) {
toUsers =
@@ -438,7 +439,7 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = findUrlInMessage()
}
fun sendPost(relayList: List<Relay>? = null) {
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
@@ -446,18 +447,18 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun sendDraft(relayList: List<Relay>? = null) {
fun sendDraft(relayList: List<RelaySetupInfo>? = null) {
viewModelScope.launch {
sendDraftSync(relayList)
}
}
suspend fun sendDraftSync(relayList: List<Relay>? = null) {
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>? = null) {
innerSendPost(relayList, draftTag)
}
private suspend fun innerSendPost(
relayList: List<Relay>? = null,
relayList: List<RelaySetupInfo>? = null,
localDraft: String?,
) = withContext(Dispatchers.IO) {
if (accountViewModel == null) {
@@ -474,13 +475,21 @@ open class NewPostViewModel() : ViewModel() {
val zapReceiver =
if (wantsForwardZapTo) {
forwardZapTo.items.map {
ZapSplitSetup(
lnAddressOrPubKeyHex = it.key.pubkeyHex,
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
weight = it.percentage.toDouble(),
isLnAddress = false,
)
forwardZapTo.items.mapNotNull { split ->
if (split.percentage > 0.00001) {
val homeRelay =
accountViewModel?.getRelayListFor(split.key)?.writeRelays()?.firstOrNull()
?: split.key.relaysBeingUsed.keys.firstOrNull { !it.contains("localhost") }
ZapSplitSetup(
lnAddressOrPubKeyHex = split.key.pubkeyHex,
relay = homeRelay,
weight = round(split.percentage.toDouble() * 10000) / 10000,
isLnAddress = false,
)
} else {
null
}
}
} else {
null
@@ -557,7 +566,7 @@ open class NewPostViewModel() : ViewModel() {
.toSet()
.toList()
account?.sendNIP24PrivateMessage(
account?.sendNIP17PrivateMessage(
message = tagger.message,
toUsers = receivers,
subject = subject.text.ifBlank { null },
@@ -571,8 +580,8 @@ open class NewPostViewModel() : ViewModel() {
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
account?.sendNIP24PrivateMessage(
if (nip17 || dmUsers.size > 1) {
account?.sendNIP17PrivateMessage(
message = tagger.message,
toUsers = dmUsers.map { it.pubkeyHex },
subject = subject.text.ifBlank { null },
@@ -843,11 +852,7 @@ open class NewPostViewModel() : ViewModel() {
}
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
}
return RichTextParser().parseValidUrls(message.text).firstOrNull()
}
open fun removeFromReplyList(userToRemove: User) {
@@ -1041,7 +1046,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
@@ -1131,10 +1136,10 @@ open class NewPostViewModel() : ViewModel() {
}
fun toggleNIP04And24() {
if (requiresNIP24) {
nip24 = true
if (requiresNIP17) {
nip17 = true
} else {
nip24 = !nip24
nip17 = !nip17
}
if (message.text.isNotBlank()) {
saveDraft()
@@ -40,7 +40,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -48,6 +47,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -108,13 +108,13 @@ fun NewUserMetadataView(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.display_name)) },
label = { Text(text = stringRes(R.string.display_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = stringResource(R.string.my_display_name),
text = stringRes(R.string.my_display_name),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -128,13 +128,13 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.about_me)) },
label = { Text(text = stringRes(R.string.about_me)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = stringResource(id = R.string.about_me),
text = stringRes(id = R.string.about_me),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -148,7 +148,7 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.avatar_url)) },
label = { Text(text = stringRes(R.string.avatar_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
@@ -173,7 +173,7 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.banner_url)) },
label = { Text(text = stringRes(R.string.banner_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
@@ -198,7 +198,7 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.website_url)) },
label = { Text(text = stringRes(R.string.website_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
@@ -214,7 +214,7 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.nip_05)) },
label = { Text(text = stringRes(R.string.nip_05)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
@@ -229,7 +229,7 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.ln_address)) },
label = { Text(text = stringRes(R.string.ln_address)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
@@ -245,13 +245,13 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
label = { Text(text = stringRes(R.string.ln_url_outdated)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = stringResource(R.string.lnurl),
text = stringRes(R.string.lnurl),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -260,13 +260,13 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.twitter)) },
label = { Text(text = stringRes(R.string.twitter)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.twitter.value,
onValueChange = { postViewModel.twitter.value = it },
placeholder = {
Text(
text = stringResource(R.string.twitter_proof_url_template),
text = stringRes(R.string.twitter_proof_url_template),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -275,13 +275,13 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.mastodon)) },
label = { Text(text = stringRes(R.string.mastodon)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.mastodon.value,
onValueChange = { postViewModel.mastodon.value = it },
placeholder = {
Text(
text = stringResource(R.string.mastodon_proof_url_template),
text = stringRes(R.string.mastodon_proof_url_template),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -290,13 +290,13 @@ fun NewUserMetadataView(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.github)) },
label = { Text(text = stringRes(R.string.github)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.github.value,
onValueChange = { postViewModel.github.value = it },
placeholder = {
Text(
text = stringResource(R.string.github_proof_url_template),
text = stringRes(R.string.github_proof_url_template),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -38,10 +38,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.events.EmptyTagList
@@ -63,15 +63,16 @@ fun NotifyRequestDialog(
val background = remember { mutableStateOf(defaultBackground) }
TranslatableRichTextViewer(
textContent,
content = textContent,
canPreview = true,
quotesLeft = 1,
Modifier.fillMaxWidth(),
EmptyTagList,
background,
textContent,
accountViewModel,
nav,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = background,
id = textContent,
callbackUri = null,
accountViewModel = accountViewModel,
nav = nav,
)
},
confirmButton = {
@@ -88,7 +89,7 @@ fun NotifyRequestDialog(
contentDescription = null,
)
Spacer(StdHorzSpacer)
Text(stringResource(R.string.error_dialog_button_ok))
Text(stringRes(R.string.error_dialog_button_ok))
}
}
},
@@ -1,337 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
relayInfo: Nip11RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
),
) {
Surface {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.padding(10.dp).fillMaxSize().verticalScroll(scrollState),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = StdPadding.fillMaxWidth(),
) {
Column {
RenderRelayIcon(
relayBriefInfo.displayUrl,
relayInfo.icon ?: relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = DoubleHorzSpacer)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row { Title(relayInfo.name?.trim() ?: "") }
Row { SubtitleContent(relayInfo.description?.trim() ?: "") }
}
}
Section(stringResource(R.string.owner))
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
}
}
Section(stringResource(R.string.software))
DisplaySoftwareInformation(relayInfo)
Section(stringResource(R.string.version))
SectionContent(relayInfo.version ?: "")
Section(stringResource(R.string.contact))
Box(modifier = Modifier.padding(start = 10.dp)) {
relayInfo.contact?.let {
if (it.startsWith("https:")) {
ClickableUrl(urlText = it, url = it)
} else if (it.startsWith("mailto:") || it.contains('@')) {
ClickableEmail(it)
} else {
SectionContent(it)
}
}
}
Section(stringResource(R.string.supports))
DisplaySupportedNips(relayInfo)
relayInfo.fees?.admission?.let {
if (it.isNotEmpty()) {
Section(stringResource(R.string.admission_fees))
it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") }
}
}
relayInfo.payments_url?.let {
Section(stringResource(R.string.payments_url))
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = it,
url = it,
)
}
}
relayInfo.limitation?.let {
Section(stringResource(R.string.limitations))
val authRequiredText =
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequiredText =
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val restrictedWritesText =
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
Column {
SectionContent(
"${stringResource(R.string.message_length)}: ${it.max_message_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
)
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
SectionContent(
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}")
SectionContent(
"${stringResource(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}",
)
SectionContent(
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
}
}
relayInfo.relay_countries?.let {
Section(stringResource(R.string.countries))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.language_tags?.let {
Section(stringResource(R.string.languages))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.tags?.let {
Section(stringResource(R.string.tags))
FlowRow { it.forEach { item -> SectionContent(item) } }
}
relayInfo.posting_policy?.let {
Section(stringResource(R.string.posting_policy))
Box(Modifier.padding(10.dp)) {
ClickableUrl(
it,
it,
)
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) {
FlowRow {
relayInfo.supported_nips?.forEach { item ->
val text = item.toString().padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md",
)
}
}
relayInfo.supported_nip_extensions?.forEach { item ->
val text = item.padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md",
)
}
}
}
}
@Composable
private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) {
val url = (relayInfo.software ?: "").replace("git+", "")
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(
urlText = url,
url = url,
)
}
}
@Composable
private fun DisplayOwnerInformation(
userHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LoadUser(baseUserHex = userHex, accountViewModel) {
Crossfade(it) {
if (it != null) {
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
fun Title(text: String) {
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
}
@Composable
fun SubtitleContent(text: String) {
Text(
text = text,
)
}
@Composable
fun Section(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = DoubleVertSpacer)
}
@Composable
fun SectionContent(text: String) {
Text(
modifier = Modifier.padding(start = 10.dp),
text = text,
)
}
@@ -47,16 +47,18 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RelayList(
val relay: Relay,
val relay: RelaySetupInfo,
val relayInfo: RelayBriefInfoCache.RelayBriefInfo,
val isSelected: Boolean,
)
@@ -68,9 +70,9 @@ data class RelayInfoDialog(
@Composable
fun RelaySelectionDialog(
preSelectedList: ImmutableList<Relay>,
preSelectedList: ImmutableList<RelaySetupInfo>,
onClose: () -> Unit,
onPost: (list: ImmutableList<Relay>) -> Unit,
onPost: (list: ImmutableList<RelaySetupInfo>) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -78,7 +80,7 @@ fun RelaySelectionDialog(
var relays by remember {
mutableStateOf(
accountViewModel.account.activeWriteRelays().map {
accountViewModel.account.connectToRelays.value.map {
RelayList(
relay = it,
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
@@ -140,7 +142,7 @@ fun RelaySelectionDialog(
}
RelaySwitch(
text = context.getString(R.string.select_deselect_all),
text = stringRes(context, R.string.select_deselect_all),
checked = selected,
onClick = {
selected = !selected
@@ -184,25 +186,29 @@ fun RelaySelectionDialog(
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
@@ -210,7 +216,7 @@ fun RelaySelectionDialog(
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
},
@@ -28,11 +28,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.coroutines.launch
import java.io.File
@@ -54,22 +54,22 @@ fun SaveToGallery(url: String) {
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
localContext,
stringRes(localContext, R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
).show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
localContext,
stringRes(localContext, R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
).show()
}
},
)
@@ -96,7 +96,7 @@ fun SaveToGallery(url: String) {
}
},
) {
Text(text = stringResource(id = R.string.save))
Text(text = stringRes(id = R.string.save))
}
}
@@ -116,22 +116,22 @@ fun SaveToGallery(
mimeType = mimeType,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
localContext,
stringRes(localContext, R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT,
).show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
localContext,
stringRes(localContext, R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT,
).show()
}
},
)
@@ -159,6 +159,6 @@ fun SaveToGallery(
},
shape = ButtonBorder,
) {
Text(text = stringResource(id = R.string.save))
Text(text = stringRes(id = R.string.save))
}
}
@@ -50,7 +50,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -58,6 +57,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@@ -114,7 +114,7 @@ private fun UploadBoxButton(
if (!isUploading) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringResource(id = R.string.upload_image),
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = tint,
)
@@ -137,8 +137,7 @@ val DefaultAnimationColors =
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8),
)
.toImmutableList()
).toImmutableList()
@Composable
fun LoadingAnimation(
@@ -164,7 +163,8 @@ fun LoadingAnimation(
CircularProgressIndicator(
modifier =
Modifier.size(size = indicatorSize)
Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
@@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
@@ -55,71 +54,69 @@ fun buildAnnotatedStringWithUrlHighlighting(
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text
.split('\n').joinToString("\n") { paragraph: String ->
paragraph
.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val startIndex = builderBefore.toString().length
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyB32$restOfWord ",
) // accounts for the \n at the end of each paragraph
builderBefore.append(
"$keyB32$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
builderAfter.append(
"$newWord$restOfWord ",
) // accounts for the \n at the end of each paragraph
builderAfter.append(
"$newWord$restOfWord ",
) // accounts for the \n at the end of each paragraph
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length),
),
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length),
),
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew),
),
)
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew),
),
)
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// if it can't parse the key, don't try to change.
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
},
} catch (e: Exception) {
if (e is CancellationException) throw e
// if it can't parse the key, don't try to change.
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}
},
)
substitutions.forEach {
@@ -144,16 +141,15 @@ fun buildAnnotatedStringWithUrlHighlighting(
if (inInsideRange != null) {
val percentInRange =
(offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange)
.roundToInt()
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
if (lastRangeThrough != null) {
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
return if (lastRangeThrough != null) {
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
} else {
return offset
offset
}
}
@@ -164,16 +160,15 @@ fun buildAnnotatedStringWithUrlHighlighting(
if (inInsideRange != null) {
val percentInRange =
(offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange)
.roundToInt()
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
if (lastRangeThrough != null) {
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
return if (lastRangeThrough != null) {
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
} else {
return offset
offset
}
}
}
@@ -0,0 +1,139 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddDMRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val postViewModel: DMRelayListViewModel = viewModel()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Text(stringRes(R.string.dm_relays_title))
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
DMRelayList(postViewModel, accountViewModel, onClose, nav)
}
}
}
}
@Composable
private fun Explanation() {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(id = R.string.dm_relays_not_found_editing),
)
Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(id = R.string.dm_relays_not_found_examples),
)
}
}
}
@@ -0,0 +1,139 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddSearchRelayListDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val postViewModel: SearchRelayListViewModel = viewModel()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Text(stringRes(R.string.search_relays_title))
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Explanation()
SearchRelayList(postViewModel, accountViewModel, onClose, nav)
}
}
}
}
@Composable
private fun Explanation() {
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(id = R.string.search_relays_not_found_editing),
)
Spacer(modifier = StdVertSpacer)
Text(
text = stringRes(id = R.string.search_relays_not_found_examples),
)
}
}
}
@@ -0,0 +1,308 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.RelayStat
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AllRelayListView(
onClose: () -> Unit,
relayToAdd: String = "",
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
val dmViewModel: DMRelayListViewModel = viewModel()
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
val nip65ViewModel: Nip65RelayListViewModel = viewModel()
val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle()
val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel()
val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle()
val searchViewModel: SearchRelayListViewModel = viewModel()
val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle()
val localViewModel: LocalRelayListViewModel = viewModel()
val localFeedState by localViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
kind3ViewModel.load(accountViewModel.account)
dmViewModel.load(accountViewModel.account)
nip65ViewModel.load(accountViewModel.account)
searchViewModel.load(accountViewModel.account)
localViewModel.load(accountViewModel.account)
privateOutboxViewModel.load(accountViewModel.account)
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = MinHorzSpacer)
Text(
text = stringRes(R.string.relay_settings),
style = MaterialTheme.typography.titleLarge,
)
SaveButton(
onPost = {
kind3ViewModel.create()
dmViewModel.create()
nip65ViewModel.create()
searchViewModel.create()
localViewModel.create()
privateOutboxViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
kind3ViewModel.clear()
dmViewModel.clear()
nip65ViewModel.clear()
searchViewModel.clear()
localViewModel.clear()
privateOutboxViewModel.clear()
onClose()
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
LazyColumn(contentPadding = FeedPadding) {
item {
SettingsCategory(
stringRes(R.string.public_home_section),
stringRes(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringRes(R.string.private_outbox_section),
stringRes(R.string.private_outbox_section_explainer),
)
}
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav)
item {
SettingsCategoryWithButton(
stringRes(R.string.search_section),
stringRes(R.string.search_section_explainer),
action = {
ResetSearchRelays(searchViewModel)
},
)
}
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringRes(R.string.local_section),
stringRes(R.string.local_section_explainer),
)
}
renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav)
item {
SettingsCategoryWithButton(
stringRes(R.string.kind_3_section),
stringRes(R.string.kind_3_section_description),
action = {
ResetKind3Relays(kind3ViewModel)
},
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
}
}
}
}
}
@Composable
fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
postViewModel.addAll(Constants.defaultRelays)
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays))
}
}
@Composable
fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
OutlinedButton(
onClick = {
postViewModel.deleteAll()
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it, RelayStat())) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringRes(R.string.default_relays))
}
}
@Composable
fun SettingsCategory(
title: String,
description: String? = null,
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
) {
Column(modifier) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
if (description != null) {
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@Composable
fun SettingsCategoryWithButton(
title: String,
description: String? = null,
action: @Composable () -> Unit,
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
) {
Row(modifier, horizontalArrangement = RowColSpacing) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
action()
}
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayStat
@Immutable
data class BasicRelaySetupInfo(
val url: String,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@Immutable
data class Kind3BasicRelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@@ -0,0 +1,99 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@Composable
fun BasicRelaySetupInfoClickableRow(
item: BasicRelaySetupInfo,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
onDelete: (BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
RelayNameAndRemoveButton(
item,
onClick,
onDelete,
ReactionRowHeightChat.fillMaxWidth(),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RelayStatusRow(
item = item,
modifier = HalfStartPadding.weight(1f),
accountViewModel = accountViewModel,
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -0,0 +1,113 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun BasicRelaySetupInfoDialog(
item: BasicRelaySetupInfo,
onDelete: (BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current
relayInfo?.let {
RelayInformationDialog(
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav,
)
}
BasicRelaySetupInfoClickableRow(
item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onDelete = onDelete,
accountViewModel = accountViewModel,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
},
)
},
)
}
@@ -0,0 +1,113 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
abstract class BasicRelaySetupInfoModel : ViewModel() {
lateinit var account: Account
private val _relays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
abstract fun getRelayList(): List<String>?
abstract fun saveRelayList(urlList: List<String>)
fun create() {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
saveRelayList(_relays.value.map { it.url })
clear()
}
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
fun clear() {
var hasModified = false
_relays.update {
val relayList = getRelayList() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
}
fun addRelay(relay: BasicRelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
hasModified = true
}
fun deleteRelay(relay: BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
hasModified = true
}
fun togglePaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
fun countToHumanReadableBytes(counter: Int) =
when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadableBytes(counter: Long) =
when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(
counter: Int,
str: String,
) = when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
else -> "$counter $str"
}
fun countToHumanReadable(
counter: Long,
str: String,
) = when {
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
else -> "$counter $str"
}
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun DMRelayList(
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderDMItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderDMItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: DMRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
class DMRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.getDMRelayList()?.relays()
}
override fun saveRelayList(urlList: List<String>) {
account.saveDMRelayList(urlList)
}
}
@@ -18,13 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
package com.vitorpamplona.amethyst.ui.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -32,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
@@ -40,23 +40,17 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -67,229 +61,105 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayStat
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.coroutines.launch
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewRelayListView(
onClose: () -> Unit,
fun Kind3RelayListView(
feedState: List<Kind3BasicRelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
relayToAdd: String = "",
onClose: () -> Unit,
nav: (String) -> Unit,
relayToAdd: String,
) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
postViewModel.deleteAll()
defaultRelays.forEach { postViewModel.addRelay(it) }
postViewModel.loadRelayDocuments()
},
) {
Text(stringResource(R.string.default_relays))
}
SaveButton(
onPost = {
postViewModel.create()
onClose()
},
true,
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
onClose()
},
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Column(
modifier =
Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding(),
),
verticalArrangement = Arrangement.SpaceAround,
) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
ServerConfig(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
}
}
Spacer(modifier = StdVertSpacer)
EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) }
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(contentPadding = FeedPadding) {
renderKind3Items(feedState, postViewModel, accountViewModel, onClose, nav, relayToAdd)
}
}
}
@Composable
fun ServerConfigHeader() {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.relay_address),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(30.dp))
Text(
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.errors),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.spam),
maxLines = 1,
fontSize = Font14SP,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(modifier = Modifier.size(2.dp))
}
}
fun LazyListScope.renderKind3Items(
feedState: List<Kind3BasicRelaySetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
relayToAdd: String,
) {
itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item ->
LoadRelayInfo(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
HorizontalDivider(thickness = DividerThickness)
item {
Spacer(modifier = StdVertSpacer)
Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) }
}
}
@Preview
@Composable
fun ServerConfigPreview() {
ServerConfigClickableLine(
ClickableRelayItem(
loadProfilePicture = true,
loadRobohash = false,
item =
RelaySetupInfo(
Kind3BasicRelaySetupInfo(
url = "nostr.mom",
read = true,
write = true,
errorCount = 23,
downloadCountInBytes = 10000,
uploadCountInBytes = 10000000,
spamCount = 10,
relayStat =
RelayStat(
errorCounter = 23,
receivedBytes = 10000,
sentBytes = 10000000,
spamCounter = 10,
),
feedTypes = Constants.activeTypesGlobalChats,
paidRelay = true,
),
@@ -306,16 +176,16 @@ fun ServerConfigPreview() {
}
@Composable
fun ServerConfig(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
fun LoadRelayInfo(
item: Kind3BasicRelaySetupInfo,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -332,14 +202,10 @@ fun ServerConfig(
)
}
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
ServerConfigClickableLine(
ClickableRelayItem(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
onToggleFollows = onToggleFollows,
@@ -351,30 +217,39 @@ fun ServerConfig(
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) },
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
@@ -382,7 +257,7 @@ fun ServerConfig(
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
},
@@ -392,17 +267,18 @@ fun ServerConfig(
}
@Composable
fun ServerConfigClickableLine(
item: RelaySetupInfo,
fun ClickableRelayItem(
item: Kind3BasicRelaySetupInfo,
loadProfilePicture: Boolean,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
loadRobohash: Boolean,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
onClick: () -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
@@ -420,6 +296,7 @@ fun ServerConfigClickableLine(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
@@ -433,13 +310,12 @@ fun ServerConfigClickableLine(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RenderActiveToggles(
ActiveToggles(
item = item,
onToggleFollows = onToggleFollows,
onTogglePrivateDMs = onTogglePrivateDMs,
onTogglePublicChats = onTogglePublicChats,
onToggleGlobal = onToggleGlobal,
onToggleSearch = onToggleSearch,
)
}
@@ -447,7 +323,7 @@ fun ServerConfigClickableLine(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
RenderStatusRow(
StatusRow(
item = item,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
@@ -463,10 +339,10 @@ fun ServerConfigClickableLine(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderStatusRow(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
private fun StatusRow(
item: Kind3BasicRelaySetupInfo,
onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
val scope = rememberCoroutineScope()
@@ -474,19 +350,20 @@ private fun RenderStatusRow(
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.read_from_relay),
contentDescription = stringRes(R.string.read_from_relay),
modifier =
Modifier.size(15.dp)
Modifier
.size(15.dp)
.combinedClickable(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.read_from_relay),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -501,7 +378,7 @@ private fun RenderStatusRow(
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.receivedBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -510,19 +387,20 @@ private fun RenderStatusRow(
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
stringRes(R.string.write_to_relay),
modifier =
Modifier.size(15.dp)
Modifier
.size(15.dp)
.combinedClickable(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.write_to_relay),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -537,7 +415,7 @@ private fun RenderStatusRow(
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
text = countToHumanReadableBytes(item.relayStat.sentBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -546,24 +424,25 @@ private fun RenderStatusRow(
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
stringRes(R.string.errors),
modifier =
Modifier.size(15.dp)
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.errors),
Toast.LENGTH_SHORT,
).show()
}
},
),
tint =
if (item.errorCount > 0) {
if (item.relayStat.errorCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -571,7 +450,7 @@ private fun RenderStatusRow(
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
text = countToHumanReadable(item.relayStat.errorCounter, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -580,24 +459,25 @@ private fun RenderStatusRow(
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
stringRes(R.string.spam),
modifier =
Modifier.size(15.dp)
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.spam),
Toast.LENGTH_SHORT,
).show()
}
},
),
tint =
if (item.spamCount > 0) {
if (item.relayStat.spamCounter > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
@@ -605,7 +485,7 @@ private fun RenderStatusRow(
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
text = countToHumanReadable(item.relayStat.spamCounter, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
@@ -615,19 +495,18 @@ private fun RenderStatusRow(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderActiveToggles(
item: RelaySetupInfo,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
private fun ActiveToggles(
item: Kind3BasicRelaySetupInfo,
onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit,
onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit,
onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Text(
text = stringResource(id = R.string.active_for),
text = stringRes(id = R.string.active_for),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.placeholderText,
@@ -641,20 +520,21 @@ private fun RenderActiveToggles(
) {
Icon(
painterResource(R.drawable.ic_home),
stringResource(R.string.home_feed),
stringRes(R.string.home_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleFollows(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.home_feed),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.home_feed),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -674,20 +554,21 @@ private fun RenderActiveToggles(
) {
Icon(
painterResource(R.drawable.ic_dm),
stringResource(R.string.private_message_feed),
stringRes(R.string.private_message_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onTogglePrivateDMs(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.private_message_feed),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.private_message_feed),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -707,20 +588,21 @@ private fun RenderActiveToggles(
) {
Icon(
imageVector = Icons.Default.Groups,
contentDescription = stringResource(R.string.public_chat_feed),
contentDescription = stringRes(R.string.public_chat_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onTogglePublicChats(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.public_chat_feed),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.public_chat_feed),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -740,20 +622,21 @@ private fun RenderActiveToggles(
) {
Icon(
imageVector = Icons.Default.Public,
stringResource(R.string.global_feed),
stringRes(R.string.global_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleGlobal(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.global_feed),
Toast.LENGTH_SHORT,
)
.show()
Toast
.makeText(
context,
stringRes(context, R.string.global_feed),
Toast.LENGTH_SHORT,
).show()
}
},
),
@@ -767,47 +650,13 @@ private fun RenderActiveToggles(
},
)
}
IconButton(
modifier = Size30Modifier,
onClick = { onToggleSearch(item) },
) {
Icon(
imageVector = Icons.Default.Search,
stringResource(R.string.search_feed),
modifier =
Modifier.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleSearch(item) },
onLongClick = {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.search_feed),
Toast.LENGTH_SHORT,
)
.show()
}
},
),
tint =
if (item.feedTypes.contains(FeedType.SEARCH)) {
MaterialTheme.colorScheme.allGoodColor
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.32f,
)
},
)
}
}
@Composable
private fun FirstLine(
item: RelaySetupInfo,
item: Kind3BasicRelaySetupInfo,
onClick: () -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
onDelete: (Kind3BasicRelaySetupInfo) -> Unit,
modifier: Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
@@ -835,7 +684,7 @@ private fun FirstLine(
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringResource(id = R.string.remove),
contentDescription = stringRes(id = R.string.remove),
modifier = Modifier.padding(start = 10.dp).size(15.dp),
tint = WarningColor,
)
@@ -844,9 +693,9 @@ private fun FirstLine(
}
@Composable
fun EditableServerConfig(
fun Kind3RelayEditBox(
relayToAdd: String,
onNewRelay: (RelaySetupInfo) -> Unit,
onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit,
) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
@@ -854,7 +703,7 @@ fun EditableServerConfig(
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.add_a_relay)) },
label = { Text(text = stringRes(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
@@ -871,7 +720,7 @@ fun EditableServerConfig(
IconButton(onClick = { read = !read }) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(id = R.string.read_from_relay),
contentDescription = stringRes(id = R.string.read_from_relay),
modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp),
tint =
if (read) {
@@ -885,7 +734,7 @@ fun EditableServerConfig(
IconButton(onClick = { write = !write }) {
Icon(
imageVector = Icons.Default.Upload,
contentDescription = stringResource(id = R.string.write_to_relay),
contentDescription = stringRes(id = R.string.write_to_relay),
modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp),
tint =
if (write) {
@@ -899,18 +748,15 @@ fun EditableServerConfig(
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
if (url.endsWith(".onion") || url.endsWith(".onion/")) {
"ws://$url"
} else {
"wss://$url"
}
} else {
url
}
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
onNewRelay(
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(url),
read = read,
write = write,
feedTypes = FeedType.entries.toSet(),
relayStat = RelayStat(),
),
)
url = ""
write = true
read = true
@@ -927,25 +773,7 @@ fun EditableServerConfig(
},
),
) {
Text(text = stringResource(id = R.string.add), color = Color.White)
Text(text = stringRes(id = R.string.add), color = Color.White)
}
}
}
fun countToHumanReadableBytes(counter: Int) =
when {
counter >= 1000000000 -> "${round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${round(counter / 1000000f)} MB"
counter >= 1000 -> "${round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(
counter: Int,
str: String,
) = when {
counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${round(counter / 1000000f)}M $str"
counter >= 1000 -> "${round(counter / 1000f)}K $str"
else -> "$counter $str"
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -27,8 +27,8 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,12 +36,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NewRelayListViewModel : ViewModel() {
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
@@ -49,9 +51,18 @@ class NewRelayListViewModel : ViewModel() {
}
fun create() {
relays.let {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
account.saveKind3RelayList(
relays.value.map {
RelaySetupInfo(
it.url,
it.read,
it.write,
it.feedTypes,
)
},
)
clear()
}
}
@@ -72,38 +83,13 @@ class NewRelayListViewModel : ViewModel() {
}
fun clear() {
hasModified = false
_relays.update {
var relayFile = account.userProfile().latestContactList?.relays()
if (relayFile != null) {
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
val needsSearchRelay =
relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } &&
relayFile.none {
account.localRelays
.filter { localRelay -> localRelay.url == it.key }
.firstOrNull()
?.feedTypes
?.contains(FeedType.SEARCH)
?: false
}
if (needsSearchRelay) {
relayFile =
relayFile +
Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write),
)
}
}
relayFile
.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes =
account.localRelays
.filter { localRelay -> localRelay.url == it.key }
@@ -115,102 +101,114 @@ class NewRelayListViewModel : ViewModel() {
?.feedTypes
?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(
it.key,
it.value.read,
it.value.write,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
localInfoFeedTypes,
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.key),
read = it.value.read,
write = it.value.write,
feedTypes = localInfoFeedTypes,
relayStat = RelayStats.get(it.key),
)
}
.distinctBy { it.url }
.sortedBy { it.downloadCountInBytes }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
} else {
account.localRelays
.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(
it.url,
it.read,
it.write,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
it.feedTypes,
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = it.read,
write = it.write,
feedTypes = it.feedTypes,
relayStat = RelayStats.get(it.url),
)
}
.distinctBy { it.url }
.sortedBy { it.downloadCountInBytes }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
}
fun addRelay(relay: RelaySetupInfo) {
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
hasModified = true
_relays.update {
defaultRelays.map {
Kind3BasicRelaySetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = it.read,
write = it.write,
feedTypes = it.feedTypes,
relayStat = RelayStats.get(it.url),
)
}
.distinctBy { it.url }
.sortedBy { it.relayStat.receivedBytes }
.reversed()
}
}
fun addRelay(relay: Kind3BasicRelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(relay) }
hasModified = true
}
fun deleteRelay(relay: RelaySetupInfo) {
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
hasModified = true
}
fun toggleDownload(relay: RelaySetupInfo) {
fun toggleDownload(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(read = !relay.read)) }
hasModified = true
}
fun toggleUpload(relay: RelaySetupInfo) {
fun toggleUpload(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.updated(relay, relay.copy(write = !relay.write)) }
hasModified = true
}
fun toggleFollows(relay: RelaySetupInfo) {
fun toggleFollows(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleMessages(relay: RelaySetupInfo) {
fun toggleMessages(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePublicChats(relay: RelaySetupInfo) {
fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleGlobal(relay: RelaySetupInfo) {
fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun toggleSearch(relay: RelaySetupInfo) {
fun toggleSearch(relay: Kind3BasicRelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
hasModified = true
}
fun togglePaidRelay(
relay: RelaySetupInfo,
relay: Kind3BasicRelaySetupInfo,
paid: Boolean,
) {
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun LocalRelayList(
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderLocalItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: LocalRelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addRelay(it) }
}
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<String>? {
return account.localRelayServers.toList()
}
override fun saveRelayList(urlList: List<String>) {
account.updateLocalRelayServers(urlList.toSet())
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun Nip65RelayList(
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderNip65HomeItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteHomeRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addHomeRelay(it) }
}
}
fun LazyListScope.renderNip65NotifItems(
feedState: List<BasicRelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
BasicRelaySetupInfoDialog(
item,
onDelete = { postViewModel.deleteNotifRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
RelayUrlEditField { postViewModel.addNotifRelay(it) }
}
}
@@ -0,0 +1,175 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _homeRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val homeRelays = _homeRelays.asStateFlow()
private val _notificationRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
val notificationRelays = _notificationRelays.asStateFlow()
var hasModified = false
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
if (hasModified) {
viewModelScope.launch(Dispatchers.IO) {
val writes = _homeRelays.value.map { it.url }.toSet()
val reads = _notificationRelays.value.map { it.url }.toSet()
val urls = writes.union(reads)
account.sendNip65RelayList(
urls.map {
val type =
if (writes.contains(it) && reads.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.BOTH
} else if (writes.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.WRITE
} else {
AdvertisedRelayListEvent.AdvertisedRelayType.READ
}
AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type)
},
)
clear()
}
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
_notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
fun clear() {
hasModified = false
_homeRelays.update {
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
_notificationRelays.update {
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
relayList.map { relayUrl ->
BasicRelaySetupInfo(
RelayUrlFormatter.normalize(relayUrl),
RelayStats.get(relayUrl),
)
}.distinctBy { it.url }.sortedBy { it.relayStat.receivedBytes }.reversed()
}
}
fun addHomeRelay(relay: BasicRelaySetupInfo) {
if (_homeRelays.value.any { it.url == relay.url }) return
_homeRelays.update { it.plus(relay) }
hasModified = true
}
fun deleteHomeRelay(relay: BasicRelaySetupInfo) {
_homeRelays.update { it.minus(relay) }
hasModified = true
}
fun deleteHomeAll() {
_homeRelays.update { relays -> emptyList() }
hasModified = true
}
fun toggleHomePaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_homeRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
fun addNotifRelay(relay: BasicRelaySetupInfo) {
if (_notificationRelays.value.any { it.url == relay.url }) return
_notificationRelays.update { it.plus(relay) }
hasModified = true
}
fun deleteNotifRelay(relay: BasicRelaySetupInfo) {
_notificationRelays.update { it.minus(relay) }
hasModified = true
}
fun deleteNotifAll() {
_notificationRelays.update { relays -> emptyList() }
hasModified = true
}
fun toggleNotifPaidRelay(
relay: BasicRelaySetupInfo,
paid: Boolean,
) {
_notificationRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}

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