Compare commits

...
187 Commits
Author SHA1 Message Date
Vitor Pamplona adaab4ced8 v0.83.2 2023-12-23 17:33:47 -05:00
Vitor Pamplona 5a2e149fa0 No need to remember simple state. 2023-12-23 17:28:04 -05:00
Vitor Pamplona 560c1f797c Performance: Reuse of modifiers in ReactionRow and Column 2023-12-23 16:28:02 -05:00
Vitor Pamplona 2d5be92f34 Performance: Improving the use or remember, less jittering when scrolling. 2023-12-23 15:28:04 -05:00
Vitor Pamplona e9285fdac0 Simplifying the reaction row zap click. 2023-12-23 12:37:10 -05:00
Vitor Pamplona 4f12c127af Adds party, thinking and shock emoji to the default set 2023-12-23 12:04:41 -05:00
Vitor Pamplona be7522382f Adds a log to debug notification receipts in production. 2023-12-23 11:38:11 -05:00
Vitor Pamplona 987fb02be1 Fixes relay icon size 2023-12-23 11:27:03 -05:00
Vitor Pamplona bbd8a34f44 Initial refactoring of the elements within a post. 2023-12-22 19:44:01 -05:00
Vitor Pamplona 720ebfd0ea Avoids creating a new byte array at every call of conversation key. 2023-12-22 17:09:36 -05:00
Vitor Pamplona 159228972c Finishing the refactoring of Optout filters 2023-12-22 14:07:37 -05:00
Vitor Pamplona 2a65944eeb Removing unused parameter in this class. 2023-12-22 14:00:47 -05:00
Vitor Pamplona e7902c4565 Testing new version 2023-12-22 13:49:29 -05:00
Vitor PamplonaandGitHub f12588ef7c Merge pull request #730 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-22 11:36:14 -05:00
Crowdin Bot 5fc197a40b New Crowdin translations by GitHub Action 2023-12-22 16:35:48 +00:00
Vitor Pamplona 64608f771e v0.83.0 2023-12-22 11:33:40 -05:00
Vitor Pamplona a9cebaf3d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-22 11:33:13 -05:00
Vitor Pamplona 6c33dae498 Small refactoring before release. 2023-12-22 11:32:54 -05:00
Vitor Pamplona e42d2dca04 Updates dependencies 2023-12-22 10:25:28 -05:00
Vitor Pamplona 35cd9cb6c5 Starts relay observers with a non null value 2023-12-22 09:15:21 -05:00
Vitor Pamplona 2ce16325a1 Improving the memory use of hashmaps 2023-12-21 15:26:01 -05:00
Vitor PamplonaandGitHub 275aeb7833 Merge pull request #729 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-21 10:58:23 -05:00
Crowdin Bot 34737be5eb New Crowdin translations by GitHub Action 2023-12-21 15:57:34 +00:00
Vitor Pamplona 6fb5c873cc Removes signingConfig for debug 2023-12-21 10:52:46 -05:00
Vitor Pamplona e0ffba5295 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-21 08:53:29 -05:00
Vitor PamplonaandGitHub 82f03e0769 Merge pull request #728 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-20 19:20:00 -05:00
Vitor Pamplona b223cc4028 updates okhttp 2023-12-20 19:19:58 -05:00
Crowdin Bot 550a9abdbb New Crowdin translations by GitHub Action 2023-12-20 23:09:16 +00:00
Vitor Pamplona a121d1b301 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-20 18:07:46 -05:00
Vitor Pamplona e794ff44a1 Moves to NIP-44v2 2023-12-20 18:07:37 -05:00
Vitor Pamplona a22b6db130 Avoids Concurrent Modification Exception 2023-12-20 14:55:12 -05:00
Vitor Pamplona 1f7fc236db Fixes thread test 2023-12-20 12:47:28 -05:00
Vitor Pamplona 1edaea7905 Fix image uploader test 2023-12-20 11:46:44 -05:00
Vitor PamplonaandGitHub e8cae710c7 Merge pull request #726 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-19 16:46:57 -05:00
Crowdin Bot 489d92f9bb New Crowdin translations by GitHub Action 2023-12-19 21:29:59 +00:00
Vitor Pamplona 4d4c568afc Updates dependencies 2023-12-19 15:54:18 -05:00
Vitor Pamplona e452c33a98 Uses primitive instead of object for Float 2023-12-19 15:54:10 -05:00
Vitor Pamplona fdfd875580 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-19 15:53:43 -05:00
Vitor Pamplona a6e8ee20ae Improves the performance of Robohash 2023-12-19 15:46:55 -05:00
Vitor Pamplona 065dba2431 Add a less memory intensive timeAgo calculation 2023-12-19 15:46:36 -05:00
Vitor PamplonaandGitHub 5f581c2f5e Merge pull request #722 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-17 19:43:23 -05:00
Crowdin Bot f7bfb68e7c New Crowdin translations by GitHub Action 2023-12-17 23:54:58 +00:00
Vitor Pamplona 2371e0678a Reusing SessionToken for all Playback connections 2023-12-17 18:44:18 -05:00
Vitor Pamplona 97c78aaa3c Moves the creation of the MediaItem to a thread. 2023-12-17 17:26:53 -05:00
Vitor Pamplona 9026c877ac - Adds i18n for error messages when uploading images.
- Forces nip95 to be under 80Kb
- Fixes bug that wouldn't show an error when uploading images in the reels page.
- Only saves default server if not NIP95
2023-12-17 17:26:17 -05:00
Vitor Pamplona 238ade9ccb Removes unused translation phrases. 2023-12-17 14:33:08 -05:00
Vitor Pamplona 016bd1ed81 Does apply the video's dimensions when the user has selected to not automatically start the playback 2023-12-17 14:30:04 -05:00
Vitor Pamplona ed21d4cafb Detecting images by pinging the server instead of relying on the url. 2023-12-16 11:55:09 -05:00
Vitor Pamplona ecc0f39f7d Moves relay modifiers to the app's theme 2023-12-16 11:42:11 -05:00
Vitor Pamplona 26846fa91a Refining recompositions of Route. 2023-12-15 22:21:43 -05:00
Vitor PamplonaandGitHub fe3a0568f2 Update README.md
Fixing the todo list in readme
2023-12-15 18:46:52 -05:00
Vitor Pamplona 56252d33f1 Updated Readme with the new refactorings from the NIP repo. 2023-12-15 18:38:06 -05:00
Vitor Pamplona fccfb2aaba Support for NIP-31 alt tags 2023-12-15 18:19:37 -05:00
Vitor Pamplona 7b55b69c12 Adds a k tag to reactions 2023-12-15 18:00:26 -05:00
Vitor Pamplona 23fad2a818 removing some old time measuring code for additional performance. 2023-12-15 15:30:16 -05:00
Vitor Pamplona 2dc23b601a Performance: uses primitive instead of the wrapped object. 2023-12-15 14:58:57 -05:00
Vitor Pamplona 504980d263 Fixes bug with cropped joinToString 2023-12-14 20:17:38 -05:00
Vitor Pamplona 7cf7c9f082 Refactoring 2023-12-14 18:56:53 -05:00
Vitor Pamplona dd8904de15 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-14 18:45:42 -05:00
Vitor Pamplona d1c43f8147 Avoiding the creation of new sets when looping through maps in User and Note 2023-12-14 18:42:36 -05:00
Vitor Pamplona 68506fd6af Avoiding the creation of new sets when looping through maps in User and Note 2023-12-14 18:31:00 -05:00
Vitor Pamplona b795ca06d1 Avoids creating another keySet. 2023-12-14 17:41:50 -05:00
Vitor Pamplona 8695acb742 No need to recreate Sets. 2023-12-14 17:41:25 -05:00
Vitor Pamplona ed491f5e3a Avoids recreating the EOSE array when changing filters. 2023-12-14 15:52:52 -05:00
Vitor Pamplona 3a76c2a2de Minor refactoring 2023-12-14 15:02:49 -05:00
Vitor Pamplona 91b3b9e0e9 Moves to a less memory intensive way to send filters to the server. 2023-12-14 14:33:15 -05:00
Vitor Pamplona fb280650a5 Ignore app insights local settings for Android studio 2023-12-14 11:03:33 -05:00
Vitor Pamplona e4baebf2f9 Must be on main. 2023-12-14 11:02:55 -05:00
Vitor Pamplona 1a33da2537 Moving buildConfig to DSL 2023-12-14 10:45:14 -05:00
Vitor Pamplona 3734ee58ea Disabling log messages 2023-12-14 10:39:16 -05:00
Vitor Pamplona 9554b36f80 Updates secp256k1 lib 2023-12-14 10:38:41 -05:00
Vitor Pamplona 5225c6ad07 v0.82.3 2023-12-13 18:57:16 -05:00
Vitor Pamplona c7be0e4a95 Memory improvements on relay lists and LiveData objects 2023-12-13 18:51:30 -05:00
Vitor Pamplona d4f060d509 Improvements to the rendering of chat headers 2023-12-13 18:50:36 -05:00
Vitor Pamplona 9ba9ddee50 Using the new ChatHeader layout 2023-12-13 15:23:46 -05:00
Vitor Pamplona 7bbdd5e877 Avoid remembering Modifier 2023-12-13 15:23:32 -05:00
Vitor Pamplona 6cc65ce84a Avoid remembering Modifier 2023-12-13 15:22:23 -05:00
Vitor Pamplona 6c711f8acc v0.82.2 2023-12-13 12:10:06 -05:00
Vitor Pamplona acefff80ee - Fixes the transition between short preview and full text on markdown.
- Adds NIP-44 metatags to markdown rendering.
- Adds background video rendering on markdown
- Refactors a few class names.
2023-12-13 12:09:59 -05:00
Vitor Pamplona 0e5b5d657f Calculate the text width of a space outside a composable. 2023-12-12 20:31:03 -05:00
Vitor Pamplona c2532322fd Fixing the image link classification with uppercase extensions. 2023-12-12 20:29:28 -05:00
Vitor Pamplona dc73aec334 No need to remember this. 2023-12-12 18:57:14 -05:00
Vitor Pamplona aa909184fd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 18:09:27 -05:00
Vitor PamplonaandGitHub 205dd75e7e Update README.md
Aligning screenshots
2023-12-12 18:09:18 -05:00
Vitor Pamplona be9100dc5e Minor refactoring. 2023-12-12 18:00:02 -05:00
Vitor Pamplona b2e35cdb3e v0.82.1 2023-12-12 11:20:33 -05:00
Vitor Pamplona 827e168bf2 Fixing minification issue on release. 2023-12-12 11:16:46 -05:00
Vitor Pamplona a14d4e6f4e v0.82.0 2023-12-12 09:32:59 -05:00
Vitor Pamplona de61c0350e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-12 09:31:25 -05:00
Vitor Pamplona 3e3c97076d Formatting 2023-12-12 09:31:14 -05:00
Vitor Pamplona f62c986016 Improving sizes of blurhashes 2023-12-12 08:50:11 -05:00
Vitor PamplonaandGitHub 04108e893d Merge pull request #720 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-12 08:44:58 -05:00
Crowdin Bot 042394796a New Crowdin translations by GitHub Action 2023-12-12 13:44:16 +00:00
Vitor Pamplona bfce57f686 Improving the video switching from blurhash to video 2023-12-11 19:58:29 -05:00
Vitor Pamplona 5bb983ba5a Adding blurhash when images are not supposed to be downloaded. 2023-12-11 17:57:19 -05:00
Vitor Pamplona debae02421 Improves the rendering of inline metadata 2023-12-11 16:22:01 -05:00
Vitor Pamplona 6fe93fdf33 Reverting image comment 2023-12-11 14:26:28 -05:00
Vitor Pamplona 3d225423ef Adds support for nip54 2023-12-11 12:51:53 -05:00
Vitor Pamplona 49e1da8812 Spacing 2023-12-11 12:50:44 -05:00
Vitor Pamplona 04addc4cba Adds support for the FileServers kind 2023-12-10 21:27:47 -05:00
Vitor Pamplona c5cf2c5442 Adding the delete option for NIP-96 2023-12-10 21:21:00 -05:00
Vitor Pamplona ddb7e6b7f2 Updates JNA 2023-12-10 18:32:47 -05:00
Vitor Pamplona e5af34efca Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-08 22:08:30 -05:00
Vitor Pamplona b3bdbbed98 - Migrates the old imageservers to NIP-96 for the main feed, chats, private messages, stories and user metadata uploads.
- Fixes hash calculation of the entire payload.
- Unifies uploads into NIP-94 images.
2023-12-08 22:08:19 -05:00
Vitor PamplonaandGitHub 656b066d3a Merge pull request #719 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:50:21 -05:00
Crowdin Bot 4248fadc69 New Crowdin translations by GitHub Action 2023-12-09 00:45:17 +00:00
Vitor PamplonaandGitHub 18281cbc49 Merge pull request #718 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-08 19:44:05 -05:00
Crowdin Bot 1d67af328a New Crowdin translations by GitHub Action 2023-12-08 16:23:27 +00:00
Vitor PamplonaandGitHub 72b4f6acef Merge pull request #715 from davotoula/tr
Correct spelling of localised string reference
2023-12-08 11:21:52 -05:00
Vitor PamplonaandGitHub 26273270ba Merge branch 'main' into tr 2023-12-08 11:21:46 -05:00
Vitor Pamplona db3824c3c7 New Cashu Redeeming card. 2023-12-06 18:43:54 -05:00
Vitor Pamplona d6de1d03d8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 15:40:05 -05:00
Vitor Pamplona 8e4b7c66b5 Adds a UserAgent to all HTTP requests. 2023-12-06 15:40:01 -05:00
Vitor Pamplona 45c750069a Refactoring the update of channel information. 2023-12-06 15:38:31 -05:00
Vitor PamplonaandGitHub a411a72024 Merge pull request #716 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 14:59:12 -05:00
Crowdin Bot 219d163680 New Crowdin translations by GitHub Action 2023-12-06 19:38:31 +00:00
Vitor Pamplona a4c9f42205 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-06 14:36:21 -05:00
Vitor Pamplona a8936c54d8 Adds a Marketplace tab to Discovery 2023-12-06 13:40:54 -05:00
David Kaspar 74875bb564 Correct spelling of localised string reference 2023-12-06 15:07:52 +01:00
Vitor PamplonaandGitHub 61d412d941 Merge pull request #714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-06 08:14:16 -05:00
Crowdin Bot 5bb1e47edf New Crowdin translations by GitHub Action 2023-12-06 13:13:27 +00:00
Vitor PamplonaandGitHub b023614b5f Merge pull request #713 from davotoula/tr
Updated translations for cs/de/sv/pt
2023-12-06 08:11:39 -05:00
David Kaspar 7245718003 Updated translations for cs/de/sv/pt 2023-12-06 12:15:02 +01:00
David Kaspar ba1f98d930 Updated translations for cs/de/sv/pt 2023-12-06 12:08:20 +01:00
David Kaspar ceeabffcb7 Correct spelling of localised string reference 2023-12-06 12:04:50 +01:00
David Kaspar 2a943493b3 Updated translations for cs/de/sv/pt 2023-12-06 11:21:41 +01:00
Vitor Pamplona 332a2f41b6 Moves the relay observer and reconnection to Service Manager's scope. 2023-12-05 21:48:43 -05:00
Vitor Pamplona ce2cfb7c86 fixes bug when updating relay list that used keep the previous list. 2023-12-05 21:48:05 -05:00
Vitor Pamplona 41ef1abf0e Fixes deletion of replaceable events. 2023-12-05 21:47:20 -05:00
Vitor Pamplona fce27c3c55 Presenting better error messages when the image upload fails. 2023-12-05 21:46:35 -05:00
Vitor Pamplona 6662966ab3 Fixing crash when image is an SVG and tries to compress. 2023-12-05 21:45:56 -05:00
Vitor Pamplona f458f00edc 1. Adds Classified creation from Amethyst.
2. Adds relay information for Replaceable events.
2023-12-05 21:44:49 -05:00
Vitor PamplonaandGitHub 8f1fbe10e9 Merge pull request #712 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-05 11:41:45 -05:00
Crowdin Bot 69efa78fbc New Crowdin translations by GitHub Action 2023-12-05 16:16:54 +00:00
Vitor Pamplona 64a07d8760 Updates to fix Crash due to malformed video formats. 2023-12-05 09:52:15 -05:00
Vitor Pamplona cb672d92d0 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 13:25:54 -05:00
Vitor Pamplona 95bcbfdded Reusing common icons for Cashu's Redemption 2023-12-04 13:23:19 -05:00
Vitor PamplonaandGitHub ac269727bc Merge pull request #711 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-04 11:57:45 -05:00
Crowdin Bot b1a7d18c0a New Crowdin translations by GitHub Action 2023-12-04 15:53:21 +00:00
Vitor Pamplona 619b59357b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-04 10:52:23 -05:00
Vitor PamplonaandGitHub 86dcc3ccd9 Merge pull request #710 from believethehype/main
add a button to cashu preview to redeem on external wallet
2023-12-04 10:51:41 -05:00
Vitor Pamplona 6772360ae7 Fixes bottom bar appearing in chats when the keyboard is open. 2023-12-04 10:48:29 -05:00
Vitor Pamplona 05cbbd79cb Uses nostr.wine instead of filter.nostr.wine as a search relay. 2023-12-04 10:47:38 -05:00
Believethehype a80c03a2d2 Update CashuRedeem.kt 2023-12-04 16:20:40 +01:00
Believethehype 6c3abe17ed add a button to cashu preview to redeem on external wallet, additional to melting to ln 2023-12-03 15:02:22 +01:00
Vitor Pamplona 7cd09261de Optimizes the rendering of the side bar. 2023-12-02 17:23:15 -05:00
Vitor Pamplona 6ef1bee8a0 Updates EOSE in the same thread of the new event to reduce the amount of coroutine launches. 2023-12-02 14:54:34 -05:00
Vitor Pamplona 66b8b04db3 Updates dependencies. 2023-12-02 14:14:15 -05:00
Vitor Pamplona 3b06abb2bc Upgrades junit benchmark 2023-12-02 14:12:10 -05:00
Vitor Pamplona 138fafa204 Updates gradle 2023-12-02 14:11:19 -05:00
Vitor Pamplona 9cbdbb8592 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-12-02 13:56:53 -05:00
Vitor Pamplona 1c0ec93cbb Testing the use of just one HttpClient for the entire app. 2023-12-02 13:55:55 -05:00
Vitor PamplonaandGitHub c6fa2b5f80 Merge pull request #708 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:46:20 -05:00
Crowdin Bot dd55ad18e3 New Crowdin translations by GitHub Action 2023-12-02 18:39:06 +00:00
Vitor PamplonaandGitHub 284da365a1 Merge pull request #707 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-02 13:37:59 -05:00
Crowdin Bot 7a06c2e9c4 New Crowdin translations by GitHub Action 2023-12-02 16:21:46 +00:00
Vitor Pamplona 61449774a1 Improves the Redeeming feedback
Improves fee calculation from the mint
2023-12-02 11:18:01 -05:00
Vitor PamplonaandGitHub 81a3baa41c Merge pull request #706 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-12-01 09:35:18 -05:00
Crowdin Bot 4b7e58dd1c New Crowdin translations by GitHub Action 2023-12-01 14:06:41 +00:00
Vitor PamplonaandGitHub 14ce42401b Merge pull request #705 from greenart7c3/main
fix zap splits when using amber with intents
2023-12-01 09:05:26 -05:00
greenart7c3 ecb8344287 fix zap splits when using amber with intents 2023-12-01 10:52:15 -03:00
Vitor Pamplona 9bc5549d76 v0.81.5 2023-11-30 17:28:58 -05:00
Vitor Pamplona 8ac8894ccd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-11-30 17:27:03 -05:00
Vitor Pamplona efb9814f1b Fixes blocked lists on Videos. 2023-11-30 17:18:41 -05:00
Vitor PamplonaandGitHub 3582f587b3 Merge pull request #704 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-11-30 17:05:19 -05:00
Crowdin Bot 4fbc9f6d53 New Crowdin translations by GitHub Action 2023-11-30 21:26:37 +00:00
Vitor Pamplona e9ec1c45a0 v0.81.4 2023-11-30 16:25:07 -05:00
Vitor Pamplona b2b7cba352 Loads basic information for all the accounts in the app. 2023-11-30 16:24:53 -05:00
Vitor Pamplona 393fbd0359 clean up 2023-11-30 15:17:21 -05:00
Vitor Pamplona 47ae42e3fe Avoiding bug when creating accounts in the background and trying to create the live set 2023-11-30 15:16:21 -05:00
Vitor Pamplona 5db3ede09e Makes Client thread safe. Forcing the disconnect of an old relay list before connecting to a new one. 2023-11-30 15:03:58 -05:00
Vitor Pamplona eca420d789 Fixing the scope in HomeDataSource. 2023-11-30 15:02:16 -05:00
Vitor Pamplona 5347373cde Avoids sending filters with empty lists. 2023-11-30 14:53:59 -05:00
Vitor Pamplona 05596cb9bf Forces websocket closure onFailure 2023-11-30 14:51:55 -05:00
Vitor Pamplona 3d1310295b No need to call two launch functions 2023-11-30 14:51:13 -05:00
Vitor Pamplona 35c780f81b Avoids bug on creating the live set in the IO thread. 2023-11-30 14:50:24 -05:00
Vitor Pamplona b021920eaa Emitting an empty list if it cannot decrypt it 2023-11-30 11:22:42 -05:00
Vitor Pamplona 2b27ac3aec Cache zap calculations in notification cards. 2023-11-30 11:22:06 -05:00
Vitor Pamplona c692c9125b Fixes null list names showing before the list is loaded 2023-11-30 10:33:48 -05:00
Vitor Pamplona 7322fd7aae Fixes hidden buttons in the Chat creation screen. 2023-11-30 10:30:12 -05:00
Vitor Pamplona 07a92cd70d Fixes all or nothing decryption of Zaps failing in a few cases. Now it has a fallback when decryption fails. 2023-11-30 10:15:36 -05:00
Vitor Pamplona 21a797be9d v0.81.3 2023-11-29 17:34:15 -05:00
Vitor Pamplona fe49cbabce Improves the rendering of LN Invoice Previews. 2023-11-29 17:22:04 -05:00
Vitor Pamplona 9f674a0088 Adds flexibility on timeouts between WIFI and mobile connections. 2023-11-29 17:21:40 -05:00
Vitor Pamplona b44e94cb16 Adds a test case for urls ending with a period. 2023-11-29 16:50:36 -05:00
Vitor Pamplona a8da645be3 Increasing timeout to account for slower connections. 2023-11-29 16:50:11 -05:00
Vitor Pamplona c6e6bf6032 Loads the user profile before building the live set. 2023-11-29 16:14:02 -05:00
246 changed files with 8890 additions and 4340 deletions
+1
View File
@@ -7,6 +7,7 @@
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/appInsightsSettings.xml
.DS_Store
/build
/captures
+9
View File
@@ -3,30 +3,39 @@
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+70 -69
View File
@@ -18,87 +18,88 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Replies and mentions (NIP-10)
- [x] Reactions (NIP-25)
- [x] Reposts (NIP-18)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
- [ ] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] User Profiles (edit/follow/unfollow - NIP-02)
- [x] Public Chats (NIP-28)
- [x] Bech Encoding support (NIP-19)
- [x] Notification Dots
- [x] Reporting and Hide User capability (NIP-56)
- [x] Automatic Translations
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Created_at Limits (NIP-22)
- [x] DNS Address (NIP-05)
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
- [x] Nostr Address (NIP-05)
- [x] Replies, mentions, Threads and Notifications (NIP-10)
- [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13)
- [ ] Proof of Work Calculations (NIP-13)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23)
- [x] User Profile Fields / Relay list (NIP-24)
- [x] Reactions (NIP-25)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Sensitive Content (NIP-36)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
- [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Online Relay Search (NIP-50)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [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] Zapraiser (NIP-TBD)
- [x] Badges (NIP-58)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [x] Moderated Communities (NIP-72)
- [ ] Zap Goals (NIP-75)
- [ ] Arbitrary Custom App Data (NIP-78)
- [x] Highlights (NIP-84)
- [x] Recommended Application Handlers (NIP-89)
- [ ] Data Vending Machine (NIP-90)
- [x] Verifiable file URLs (NIP-94)
- [x] Binary Blobs (NIP-95)
- [x] HTTP File Storage Integration (NIP-96 Draft)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Private Messages and Small Groups (NIP-24/Draft)
- [x] Gift Wraps & Seals (NIP-59/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
- [x] Hashtag Following and Custom Hashtags
- [x] Login with QR
- [x] Bounty support (nostrbounties.com)
- [x] De-googled F-Droid flavor
- [x] External Identity Support (NIP-39)
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Workspaces
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] OpenTimestamps Attestations (NIP-03)
# Development Overview
@@ -204,7 +205,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
# Screenshots
| Home Feed | Messages | Live Streams | Notifications |
| FollowFeeds | ChatsGroup | LiveStreams | Notifications |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
+77 -76
View File
@@ -13,83 +13,83 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 333
versionName "0.81.2"
versionCode 343
versionName "0.83.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += [
'ar',
'bn-rBD',
'cs',
'cy-rGB',
'da-rDK',
'de',
'el-rGB',
'en-rGB',
'eo',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'night',
'nl',
'nl-rBE',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'th',
'tr',
'uk',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
'ar',
'bn-rBD',
'cs',
'cy-rGB',
'da-rDK',
'de',
'el-rGB',
'en-rGB',
'eo',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'night',
'nl',
'nl-rBE',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'th',
'tr',
'uk',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
buildTypes {
release {
//signingConfig signingConfigs.debug
signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
@@ -134,6 +134,7 @@ android {
buildFeatures {
compose true
buildConfig true
}
composeOptions {
@@ -158,7 +159,7 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.1'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -187,16 +188,16 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.5.1'
implementation 'net.engawapg.lib:zoomable:1.5.2'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.2'
implementation 'org.jsoup:jsoup:1.17.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
@@ -233,9 +234,9 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
@@ -266,14 +267,14 @@ dependencies {
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.8'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation "androidx.test.ext:junit-ktx:1.2.0-alpha01"
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
@@ -2,73 +2,116 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.Nip96Retriever
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.KeyPair
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val contentType = "image/gif"
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val contentTypePng = "image/png"
val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
val bytes = Base64.getDecoder().decode(imagePng)
val inputStream = bytes.inputStream()
val countDownLatch = CountDownLatch(1)
var url: String? = null
var error: String? = null
val account = Account(KeyPair())
ImageUploader(Account(KeyPair())).uploadImage(
val result = Nip96Uploader(account).uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
server,
onSuccess = { newUrl, contentType ->
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
},
onError = {
println("Failed to Upload")
error = it.message
countDownLatch.countDown()
contentTypePng,
alt = null,
sensitiveContent = null,
serverInfo,
onProgress = {
}
)
countDownLatch.await()
val url = result.tags!!.first() { it[0] == "url" }.get(1)
val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)?.ifBlank { null }
val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)?.ifBlank { null }
val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)?.ifBlank { null }
val contentType = result.tags!!.first() { it[0] == "m" }.get(1)
val ox = result.tags!!.first() { it[0] == "ox" }.get(1)
Assert.assertNull(error)
Assert.assertTrue(url?.startsWith("http") == true)
Assert.assertTrue(url.startsWith("http"))
val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run {
fail("Should not be null")
return
}
FileHeader.prepare(
imageData,
contentTypePng,
null,
onReady = {
if (dim != null) {
assertEquals(dim, it.dim)
}
if (size != null) {
assertEquals(size, it.size.toString())
}
if (hash != null) {
assertEquals(hash, it.hash)
}
},
onError = {
fail("It should not fail")
}
)
// delay(1000)
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
}
@Test()
fun testImgurUpload() = runBlocking {
testBase(ImgurServer())
fun testNostrCheck() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
}
@Test()
fun testNostrBuildUpload() = runBlocking {
testBase(NostrBuildServer())
fun testNostrage() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
}
@Test()
fun testNostrImgUpload() = runBlocking {
testBase(NostrImgServer())
fun testSove() = runBlocking {
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
testBase(NostrFilesDevServer())
fun testNostrBuild() = runBlocking {
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
}
@Test()
fun testSovbit() = runBlocking {
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
}
@Test()
fun testVoidCat() = runBlocking {
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
}
}
@@ -4127,6 +4127,31 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
}
@Test
fun testUrlsEndingInPeriod() {
val text = "Thats it! http://vitorpamplona.com/. Thats the note"
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it!)",
"Link(http://vitorpamplona.com/.)",
"RegularText(Thats)",
"RegularText(the)",
"RegularText(note)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { seg ->
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -9,8 +10,13 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.Event
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ThreadAssemblerTest {
val db = """
[
@@ -85,7 +91,7 @@ class ThreadAssemblerTest {
""".trimIndent()
@Test
fun threadOrderTest() {
fun threadOrderTest() = runBlocking {
val eventArray = Event.mapper.readValue<ArrayList<Event>>(db) as List<Event> + Event.fromJson(header)
var counter = 0
@@ -97,7 +103,12 @@ class ThreadAssemblerTest {
val naddr = ATag(30023, "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "1680612926599", null)
val filter = ThreadFeedFilter(Account(KeyPair()), naddr.toTag())
val account = Account(KeyPair())
withContext(Dispatchers.Main) {
val user = account.userProfile().live()
}
val filter = ThreadFeedFilter(account, naddr.toTag())
val calculatedFeed = filter.feed()
val expecteedOrder = listOf(
@@ -15,13 +15,13 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseThemeType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.hexToByteArray
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
@@ -57,9 +59,6 @@ private object PrefKeys {
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
@@ -102,6 +101,7 @@ object LocalPreferences {
private var _currentAccount: String? = null
private var _savedAccounts: List<AccountInfo>? = null
private var _cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
suspend fun currentAccount(): String? {
if (_currentAccount == null) {
@@ -264,7 +264,7 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
@@ -289,9 +289,9 @@ object LocalPreferences {
}
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
return currentAccount()?.let {
loadCurrentAccountFromEncryptedStorage(it)
}
}
suspend fun migrateOldSharedSettings(): Settings? {
@@ -379,7 +379,23 @@ object LocalPreferences {
}
}
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) {
mutex.withLock {
if (_cachedAccounts.containsKey(npub)) {
return@withContext _cachedAccounts.get(npub)
}
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
account?.registerObservers()
_cachedAccounts.put(npub, account)
return@withContext account
}
}
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
@@ -387,9 +403,6 @@ object LocalPreferences {
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
@@ -414,9 +427,15 @@ object LocalPreferences {
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val defaultFileServer = try {
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
} ?: Nip96MediaServers.DEFAULT[0]
} catch (e: Exception) {
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
e.printStackTrace()
Nip96MediaServers.DEFAULT[0]
}
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
@@ -485,7 +504,7 @@ object LocalPreferences {
NostrSignerInternal(keyPair)
}
return@with Account(
val account = Account(
keyPair = keyPair,
signer = signer,
localRelays = localRelays,
@@ -512,6 +531,16 @@ object LocalPreferences {
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute
)
// Loads from DB
account.userProfile()
withContext(Dispatchers.Main) {
// Loads Live Objects
account.userProfile().live()
}
return@with account
}
}
}
@@ -1,11 +0,0 @@
package com.vitorpamplona.amethyst
object OptOutFromFilters {
var warnAboutPostsWithReports: Boolean = true
var filterSpamFromStrangers: Boolean = true
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnAboutReports
filterSpamFromStrangers = filterSpam
}
}
@@ -28,9 +28,15 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -39,6 +45,9 @@ class ServiceManager {
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
private var account: Account? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var collectorJob: Job? = null
private fun start(account: Account) {
this.account = account
start()
@@ -55,7 +64,7 @@ class ServiceManager {
// Resets Proxy Use
HttpClient.start(account?.proxy)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
Coil.setImageLoader {
Amethyst.instance.imageLoaderBuilder().components {
if (Build.VERSION.SDK_INT >= 28) {
@@ -72,10 +81,30 @@ class ServiceManager {
}
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
Client.reconnect(relaySet)
collectorJob?.cancel()
collectorJob = null
collectorJob = scope.launch {
myAccount.userProfile().flow().relays.stateFlow.collect {
if (isStarted) {
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
Client.reconnect(newRelaySet, onlyIfChanged = true)
}
}
}
// start services
NostrAccountDataSource.account = myAccount
NostrAccountDataSource.otherAccounts = LocalPreferences.allSavedAccounts().mapNotNull {
try {
it.npub.bechToBytes().toHexKey()
} catch (e: Exception) {
null
}
}
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
@@ -102,6 +131,9 @@ class ServiceManager {
private fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
collectorJob?.cancel()
collectorJob = null
NostrAccountDataSource.stopSync()
NostrHomeDataSource.stopSync()
NostrChannelDataSource.stopSync()
@@ -120,7 +152,7 @@ class ServiceManager {
NostrUserProfileDataSource.stopSync()
NostrVideoDataSource.stopSync()
Client.disconnect()
Client.reconnect(null)
isStarted = false
}
@@ -181,4 +213,9 @@ class ServiceManager {
fun pauseForGood() {
forceRestart(null, false, true)
}
fun pauseForGoodAndClearAccount() {
account = null
forceRestart(null, false, true)
}
}
@@ -11,15 +11,14 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
@@ -31,6 +30,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GeneralListEvent
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP24Factory
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
@@ -92,6 +94,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.net.Proxy
import java.util.Locale
import java.util.UUID
import kotlin.coroutines.resume
val DefaultChannels = setOf(
@@ -99,7 +102,7 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02")
val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02", "\uD83C\uDF89", "\uD83E\uDD14", "\uD83D\uDE31")
val DefaultZapAmounts = listOf(500L, 1000L, 5000L)
@@ -128,7 +131,7 @@ class Account(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
var reactionChoices: List<String> = DefaultReactions,
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
var defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
@@ -210,6 +213,8 @@ class Account(
}
result?.let {
emit(it)
} ?: run {
emit(LiveFollowLists())
}
}
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
@@ -241,6 +246,8 @@ class Account(
}
result?.let {
emit(it)
} ?: run {
emit(LiveFollowLists())
}
}
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
@@ -272,6 +279,8 @@ class Account(
}
result?.let {
emit(it)
} ?: run {
emit(LiveFollowLists())
}
}
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
@@ -303,6 +312,8 @@ class Account(
}
result?.let {
emit(it)
} ?: run {
emit(LiveFollowLists())
}
}
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
@@ -338,18 +349,22 @@ class Account(
) { localLive, blockList, muteList ->
checkNotInMainThread()
val resultBlockList = withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
(blockList.note.event as? PeopleListEvent)?.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
val resultBlockList = (blockList.note.event as? PeopleListEvent)?.let {
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
it.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
}
}
}
} ?: PeopleListEvent.UsersAndWords()
val resultMuteList = withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
(muteList.note.event as? MuteListEvent)?.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
val resultMuteList = (muteList.note.event as? MuteListEvent)?.let {
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
it.publicAndPrivateUsersAndWords(signer) {
continuation.resume(it)
}
}
}
} ?: PeopleListEvent.UsersAndWords()
@@ -410,7 +425,7 @@ class Account(
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
LocalCache.antiSpam.active = filterSpamFromStrangers
if (!filterSpamFromStrangers) {
transientHiddenUsers = persistentSetOf()
}
@@ -436,7 +451,7 @@ class Account(
val contactList = userProfile().latestContactList
if (contactList != null && contactList.tags.isNotEmpty()) {
val event = ContactListEvent.updateRelayList(
ContactListEvent.updateRelayList(
earlierVersion = contactList,
relayUse = relays,
signer = signer
@@ -445,7 +460,7 @@ class Account(
LocalCache.justConsume(it, null)
}
} else {
val event = ContactListEvent.createFromScratch(
ContactListEvent.createFromScratch(
followUsers = listOf(),
followTags = listOf(),
followGeohashes = listOf(),
@@ -461,10 +476,10 @@ class Account(
}
}
suspend fun sendNewUserMetadata(toString: String, identities: List<IdentityClaim>) {
suspend fun sendNewUserMetadata(toString: String, newName: String, identities: List<IdentityClaim>) {
if (!isWriteable()) return
MetadataEvent.create(toString, identities, signer) {
MetadataEvent.create(toString, newName, identities, signer) {
Client.send(it)
LocalCache.justConsume(it, null)
}
@@ -683,7 +698,9 @@ class Account(
suspend fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull {
it.event?.id()
}
if (myNotes.isNotEmpty()) {
DeletionEvent.create(myNotes, signer) {
@@ -693,7 +710,7 @@ class Account(
}
}
fun createHTTPAuthorization(url: String, method: String, body: String? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
if (!isWriteable()) return
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
@@ -949,7 +966,13 @@ class Account(
}
}
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader, onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit) {
fun createNip95(
byteArray: ByteArray,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit
) {
if (!isWriteable()) return
FileStorageEvent.create(
@@ -964,8 +987,8 @@ class Account(
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
alt = alt,
sensitiveContent = sensitiveContent,
signer = signer
) { signedEvent ->
onReady(
@@ -975,7 +998,7 @@ class Account(
}
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
if (!isWriteable()) return null
Client.send(data, relayList = relayList)
@@ -987,7 +1010,19 @@ class Account(
return LocalCache.notes[signedEvent.id]
}
private fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
LocalCache.consume(data, null)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null) {
Client.send(data, relayList = relayList)
Client.send(signedEvent, relayList = relayList)
}
fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null)
@@ -996,24 +1031,123 @@ class Account(
}
}
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
fun createHeader(
imageUrl: String,
magnetUri: String?,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
onReady: (FileHeaderEvent) -> Unit
) {
if (!isWriteable()) return
FileHeaderEvent.create(
url = headerInfo.url,
url = imageUrl,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer
) { event ->
onReady(event)
}
}
fun sendHeader(
imageUrl: String,
magnetUri: String?,
headerInfo: FileHeader,
alt: String?,
sensitiveContent: Boolean,
originalHash: String? = null,
relayList: List<Relay>? = null,
onReady: (Note) -> Unit
) {
if (!isWriteable()) return
FileHeaderEvent.create(
url = imageUrl,
magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent,
signer = signer
) { event ->
sendHeader(event, relayList = relayList, onReady)
}
}
fun sendClassifieds(
title: String,
price: Price,
condition: ClassifiedsEvent.CONDITION,
location: String,
category: String,
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
directMentions: Set<HexKey>,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null
) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = replyTo?.mapNotNull { it.address() }
ClassifiedsEvent.create(
dTag = UUID.randomUUID().toString(),
title = title,
price = price,
condition = condition,
summary = message,
image = null,
location = location,
category = category,
message = message,
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
replyTo?.forEach {
it.event?.let {
Client.send(it, relayList = relayList)
}
}
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
fun sendPost(
message: String,
replyTo: List<Note>?,
@@ -1026,7 +1160,8 @@ class Account(
root: String?,
directMentions: Set<HexKey>,
relayList: List<Relay>? = null,
geohash: String? = null
geohash: String? = null,
nip94attachments: List<Event>? = null
) {
if (!isWriteable()) return
@@ -1047,6 +1182,7 @@ class Account(
root = root,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer
) {
Client.send(it, relayList = relayList)
@@ -1084,7 +1220,8 @@ class Account(
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
relayList: List<Relay>? = null,
geohash: String? = null
geohash: String? = null,
nip94attachments: List<Event>? = null
) {
if (!isWriteable()) return
@@ -1106,7 +1243,8 @@ class Account(
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash
geohash = geohash,
nip94attachments = nip94attachments
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
@@ -1125,7 +1263,7 @@ class Account(
}
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex }
@@ -1140,6 +1278,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer
) {
Client.send(it)
@@ -1147,7 +1286,7 @@ class Account(
}
}
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -1163,6 +1302,7 @@ class Account(
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer
) {
Client.send(it)
@@ -1470,6 +1610,16 @@ class Account(
return LocalCache.getOrCreateAddressableNote(aTag)
}
fun getFileServersNote(): AddressableNote {
val aTag = ATag(
FileServersEvent.kind,
userProfile().pubkeyHex,
"",
null
)
return LocalCache.getOrCreateAddressableNote(aTag)
}
fun getBlockList(): PeopleListEvent? {
return getBlockListNote().event as? PeopleListEvent
}
@@ -1478,6 +1628,10 @@ class Account(
return getMuteListNote().event as? MuteListEvent
}
fun getFileServersList(): FileServersEvent? {
return getFileServersNote().event as? FileServersEvent
}
fun hideWord(word: String) {
val muteList = getMuteList()
@@ -1489,7 +1643,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
} else {
MuteListEvent.createListWithWord(
@@ -1498,7 +1652,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
}
@@ -1514,7 +1668,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
@@ -1528,7 +1682,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
}
@@ -1544,7 +1698,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
} else {
MuteListEvent.createListWithUser(
@@ -1553,7 +1707,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
}
@@ -1569,7 +1723,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
@@ -1583,7 +1737,7 @@ class Account(
signer = signer
) {
Client.send(it)
LocalCache.consume(it)
LocalCache.consume(it, null)
}
}
@@ -1598,7 +1752,7 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultFileServer(server: ServersAvailable) {
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
defaultFileServer = server
live.invalidateData()
saveable.invalidateData()
@@ -1761,7 +1915,7 @@ class Account(
?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes
?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
} ?: return null
// Ugly, but forces nostr.band as the only search-supporting relay today.
@@ -1774,8 +1928,7 @@ class Account(
it.url,
it.read,
it.write,
it.feedTypes,
proxy
it.feedTypes
)
}
}
@@ -1785,7 +1938,7 @@ class Account(
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
@@ -1799,15 +1952,6 @@ class Account(
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
}
fun reconnectIfRelaysHaveChanged() {
val newRelaySet = activeRelays() ?: convertLocalRelays()
if (!Client.isSameRelaySetConfig(newRelaySet)) {
Client.disconnect()
Client.connect(newRelaySet)
RelayPool.requestAndWatch()
}
}
fun isAllHidden(users: Set<HexKey>): Boolean {
return users.all { isHidden(it) }
}
@@ -1886,7 +2030,7 @@ class Account(
).toSet()
}
suspend fun saveRelayList(value: List<RelaySetupInfo>) {
fun saveRelayList(value: List<RelaySetupInfo>) {
try {
localRelays = value.toSet()
return sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) })
@@ -1932,13 +2076,6 @@ class Account(
}
suspend fun registerObservers() = withContext(Dispatchers.Main) {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged()
}
}
// saves contact list for the next time.
userProfile().live().follows.observeForever {
GlobalScope.launch(Dispatchers.IO) {
@@ -4,7 +4,6 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -19,10 +18,12 @@ class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
var active: Boolean = true
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
if (!OptOutFromFilters.filterSpamFromStrangers) return false
if (!active) return false
val idHex = event.id
@@ -20,11 +20,8 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -276,7 +277,7 @@ object LocalCache {
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
}
}
@@ -426,16 +427,17 @@ object LocalCache {
}
}
fun consume(event: MuteListEvent) { consumeBaseReplaceable(event) }
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: FileServersEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -458,7 +460,7 @@ object LocalCache {
}
}
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
@@ -504,14 +506,14 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -521,6 +523,11 @@ object LocalCache {
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -531,7 +538,7 @@ object LocalCache {
}
}
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -776,7 +783,7 @@ object LocalCache {
fun consume(event: ChannelMetadataEvent) {
val channelId = event.channel()
// Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
// Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}")
if (channelId.isNullOrBlank()) return
// new event
@@ -784,10 +791,8 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -1511,26 +1516,26 @@ object LocalCache {
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event)
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AdvertisedRelayListEvent -> consume(event, relay)
is AppDefinitionEvent -> consume(event, relay)
is AppRecommendationEvent -> consume(event, relay)
is AudioHeaderEvent -> consume(event, relay)
is AudioTrackEvent -> consume(event)
is AudioTrackEvent -> consume(event, relay)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event, relay)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is CalendarEvent -> consume(event)
is CalendarDateSlotEvent -> consume(event)
is CalendarTimeSlotEvent -> consume(event)
is CalendarRSVPEvent -> consume(event)
is CalendarEvent -> consume(event, relay)
is CalendarDateSlotEvent -> consume(event, relay)
is CalendarTimeSlotEvent -> consume(event, relay)
is CalendarRSVPEvent -> consume(event, relay)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event)
is ClassifiedsEvent -> consume(event, relay)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
@@ -1540,11 +1545,12 @@ object LocalCache {
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay)
@@ -1563,15 +1569,15 @@ object LocalCache {
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is MuteListEvent -> consume(event)
is NNSEvent -> comsume(event)
is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
is RelaySetEvent -> consume(event, relay)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
@@ -41,7 +42,6 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@@ -93,7 +93,7 @@ open class Note(val idHex: String) {
var zapPayments = mapOf<Note, Note?>()
private set
var relays = listOf<String>()
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
@@ -110,13 +110,13 @@ open class Note(val idHex: String) {
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
relays.firstOrNull()?.url
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
}
@@ -271,7 +271,7 @@ open class Note(val idHex: String) {
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
zapsAmount = BigDecimal.ZERO
relays = listOf<String>()
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
liveSet?.innerReplies?.invalidateData()
@@ -287,7 +287,7 @@ open class Note(val idHex: String) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
if (reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
if (note in it) {
val newList = it.minus(note)
@@ -306,7 +306,7 @@ open class Note(val idHex: String) {
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
@@ -327,12 +327,11 @@ open class Note(val idHex: String) {
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
zapPayments = zapPayments.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
}
}
@@ -346,10 +345,7 @@ open class Note(val idHex: String) {
@Synchronized
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
return true
} else if (zaps[zapRequest] == null) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
return true
}
@@ -359,13 +355,8 @@ open class Note(val idHex: String) {
fun addZap(zapRequest: Note, zap: Note?) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
@@ -376,10 +367,7 @@ open class Note(val idHex: String) {
@Synchronized
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
} else if (zapPayments[zapPaymentRequest] == null) {
if (zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
}
@@ -389,12 +377,7 @@ open class Note(val idHex: String) {
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
}
} else if (zapPayments[zapPaymentRequest] == null) {
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
@@ -406,11 +389,12 @@ open class Note(val idHex: String) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.innerReactions?.invalidateData()
}
}
@@ -418,25 +402,27 @@ open class Note(val idHex: String) {
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
val reportsByAuthor = reports[author]
if (reportsByAuthor == null) {
reports = reports + Pair(author, listOf(note))
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
} else if (!reportsByAuthor.contains(note)) {
reports = reports + Pair(author, reportsByAuthor + note)
liveSet?.innerReports?.invalidateData()
}
}
@Synchronized
fun addRelaySync(url: String) {
if (url !in relays) {
relays = relays + url
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
if (briefInfo !in relays) {
relays = relays + briefInfo
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
addRelaySync(relay.url)
if (relay.brief !in relays) {
addRelaySync(relay.brief)
liveSet?.innerRelays?.invalidateData()
}
}
@@ -523,17 +509,17 @@ open class Note(val idHex: String) {
return reports[user]?.isNotEmpty() ?: false
}
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
return reports.count { it.key.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
return reports.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}.flatten()
}
@@ -541,8 +527,8 @@ open class Note(val idHex: String) {
var sumOfAmounts = BigDecimal.ZERO
// Regular Zap Receipts
zaps.values.forEach {
val noteEvent = it?.event
zaps.forEach {
val noteEvent = it?.value?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
}
@@ -647,8 +633,8 @@ open class Note(val idHex: String) {
val dayAgo = TimeUtils.oneDayAgo()
return reports.isNotEmpty() ||
(
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
author?.reports?.any {
it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
} ?: false
)
}
@@ -863,21 +849,17 @@ class NoteLiveSet(u: Note) {
}.distinctUntilChanged()
val reactionCount = innerReactions.map {
it.note.reactions.values.sumOf { it.size }
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}.distinctUntilChanged()
val boostCount = innerBoosts.map {
it.note.boosts.size
}.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
val relayInfo = innerRelays.map {
it.note.relays.map {
RelayBriefInfo(it)
}.toImmutableList()
it.note.relays
}
val content = innerMetadata.map {
@@ -897,8 +879,7 @@ class NoteLiveSet(u: Note) {
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
boostCount.hasObservers()
}
fun destroy() {
@@ -986,9 +967,22 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
@Immutable
class NoteState(val note: Note)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)
fun get(url: String): RelayBriefInfo {
val info = cache[url]
if (info != null) return info
val newInfo = RelayBriefInfo(url)
cache.put(url, newInfo)
return newInfo
}
}
@@ -15,5 +15,5 @@ data class RelaySetupInfo(
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@@ -1,16 +0,0 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}
@@ -34,7 +34,7 @@ object UrlCachedPreviewer {
}
}
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
val state = if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
@@ -123,16 +124,18 @@ class User(val pubkeyHex: String) {
}
liveSet?.innerRelays?.invalidateData()
flowSet?.relays?.invalidateData()
}
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
liveSet?.innerReports?.invalidateData()
}
}
@@ -140,7 +143,7 @@ class User(val pubkeyHex: String) {
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
@@ -149,48 +152,49 @@ class User(val pubkeyHex: String) {
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.innerZaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
var amount = BigDecimal.ZERO
zaps.forEach {
val itemValue = (it.value?.event as? LnZapEvent)?.amount
if (itemValue != null) {
amount += itemValue
}
}
return amount
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
return reports.count { it.key.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
return reports.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}.flatten()
}
@@ -312,7 +316,7 @@ class User(val pubkeyHex: String) {
}
suspend fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -336,7 +340,7 @@ class User(val pubkeyHex: String) {
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
@@ -356,6 +360,7 @@ class User(val pubkeyHex: String) {
}
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
@@ -383,6 +388,47 @@ class User(val pubkeyHex: String) {
}
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = UserFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
fun flow(): UserFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class UserFlowSet(u: User) {
// Observers line up here.
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
relays.destroy()
}
}
@Stable
@@ -491,6 +537,27 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
}
}
@Stable
class UserBundledRefresherFlow(val user: User) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
stateFlow.emit(UserState(user))
}
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -61,6 +62,31 @@ val noProtocolUrlValidator = try {
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlImage(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip44UrlParser().parse(fullUrl)
ZoomableUrlVideo(
url = fullUrl,
description = frags["alt"],
hash = frags["x"],
blurhash = frags["blurhash"],
dim = frags["dim"]
)
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
@@ -85,14 +111,7 @@ class RichTextParser() {
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
parseMediaUrl(fullUrl)
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
@@ -18,8 +18,6 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonNode
)
@@ -38,10 +36,8 @@ class CashuProcessor {
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
@@ -53,10 +49,29 @@ class CashuProcessor {
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError, context)
milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees
message = "Calculate Fees for Cashu",
onSuccess = { baseInvoice ->
feeCalculator(
token.mint,
baseInvoice,
onSuccess = { fees ->
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, fees, onSuccess, onError, context)
},
onProgress = {
},
onError = onError,
context = context
)
},
onError = onError,
context
)
},
onProgress = {
},
@@ -66,7 +81,59 @@ class CashuProcessor {
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
fun feeCalculator(
mintAddress: String,
invoice: String,
onSuccess: (Int) -> Unit,
onError: (String, String) -> Unit,
context: Context
) {
checkNotInMainThread()
try {
val client = HttpClient.getHttpClient()
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val feeCost = tree?.get("fee")?.asInt()
if (feeCost != null) {
onSuccess(
feeCost
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
onError(
context.getString(R.string.cashu_failed_redemption),
if (msg != null) {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
} else {
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
}
)
}
}
} catch (e: Exception) {
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
}
}
private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
@@ -92,8 +159,8 @@ class CashuProcessor {
if (successful) {
onSuccess(
context.getString(R.string.cashu_sucessful_redemption),
context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString())
context.getString(R.string.cashu_successful_redemption),
context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString())
)
} else {
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
@@ -108,7 +175,7 @@ class CashuProcessor {
}
}
} catch (e: Exception) {
onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
}
}
}
@@ -2,47 +2,54 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.BitmapParams
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import java.io.IOException
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val alt: String? = null,
val sensitiveContent: Boolean = false
val blurHash: String?
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(
fileUrl: String,
mimeType: String?,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: (String?) -> Unit
) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
} else {
onError()
onError(null)
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
onError(e.message)
}
}
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
dimPrecomputed: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
onError: (String?) -> Unit
) {
try {
val hash = CryptoUtils.sha256(data).toHexKey()
@@ -75,15 +82,125 @@ class FileHeader(
} else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height
)
if (aspectRatio > 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
if (newDim != "0x0") {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else {
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
onError(e.message)
}
}
}
}
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
val raw: ByteArray? = getEmbeddedPicture()
if (raw != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val params = BitmapParams()
params.preferredConfig = Bitmap.Config.ARGB_8888
// Fall back to middle of video
// Note: METADATA_KEY_DURATION unit is in ms, not us.
val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
} else {
null
}
} else {
null
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
return if (widthData.isNullOrEmpty()) {
null
} else {
widthData.toInt()
}
}
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
return if (heightData.isNullOrEmpty()) {
null
} else {
heightData.toInt()
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
@Throws(IOException::class)
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= imageData.size) {
return -1
}
val newSize = if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
return newSize
}
@Throws(IOException::class)
override fun close() {}
}
@@ -1,13 +1,25 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
import kotlin.properties.Delegates
object HttpClient {
val DEFAULT_TIMEOUT_ON_WIFI = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE = Duration.ofSeconds(30L)
var proxyChangeListeners = ArrayList<() -> Unit>()
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
var defaultHttpClient: OkHttpClient? = null
// fires off every time value of the property changes
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
@@ -19,20 +31,60 @@ object HttpClient {
}
fun start(proxy: Proxy?) {
this.internalProxy = proxy
if (internalProxy != proxy) {
this.internalProxy = proxy
this.defaultHttpClient = getHttpClient()
}
}
fun getHttpClient(): OkHttpClient {
val seconds = if (internalProxy != null) 20L else 10L
fun changeTimeouts(timeout: Duration) {
Log.d("HttpClient", "Changing timeout to: $timeout")
if (this.defaultTimeout.seconds != timeout.seconds) {
this.defaultTimeout = timeout
this.defaultHttpClient = getHttpClient()
}
}
fun getHttpClient(timeout: Duration): OkHttpClient {
val seconds = if (internalProxy != null) timeout.seconds * 2 else timeout.seconds
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(internalProxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.addInterceptor(DefaultContentTypeInterceptor())
.followRedirects(true)
.followSslRedirects(true)
.build()
}
class DefaultContentTypeInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request = originalRequest
.newBuilder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(requestWithUserAgent)
}
}
fun getHttpClientForRelays(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getHttpClient(): OkHttpClient {
if (this.defaultHttpClient == null) {
this.defaultHttpClient = getHttpClient(defaultTimeout)
}
return defaultHttpClient!!
}
fun getProxy(): Proxy? {
return internalProxy
}
@@ -9,7 +9,7 @@ import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
class Nip05NostrAddressVerifier() {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
class Nip30CustomEmoji {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service
import java.net.URI
import java.net.URLDecoder
class Nip44UrlParser {
fun parse(url: String): Map<String, String> {
return try {
fragments(URI(url))
} catch (e: Exception) {
emptyMap()
}
}
private fun fragments(uri: URI): Map<String, String> {
if (uri.rawFragment == null) return emptyMap()
return uri.rawFragment.split('&').associate { keyValuePair ->
val parts = keyValuePair.split('=')
val name = parts.firstOrNull() ?: ""
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
Pair(name, value)
}
}
}
@@ -6,7 +6,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {
object Nip47WalletConnectParser {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.Request
object Nip96MediaServers {
val DEFAULT =
listOf(
ServerName("Nostr.Build", "https://nostr.build"),
ServerName("NostrCheck.me", "https://nostrcheck.me"),
ServerName("Nostrage", "https://nostrage.com"),
ServerName("Sove", "https://sove.rent"),
ServerName("Sovbit", "https://files.sovbit.host"),
ServerName("Void.cat", "https://void.cat")
)
data class ServerName(val name: String, val baseUrl: String)
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
suspend fun load(url: String): Nip96Retriever.ServerInfo {
val cached = cache[url]
if (cached != null) return cached
val fetched = Nip96Retriever().loadInfo(url)
cache[url] = fetched
return fetched
}
}
class Nip96Retriever {
data class ServerInfo(
@JsonProperty("api_url") val apiUrl: String,
@JsonProperty("download_url") val downloadUrl: String? = null,
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
@JsonProperty("tos_url") val tosUrl: String? = null,
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf()
)
data class Plan(
@JsonProperty("name") val name: String? = null,
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
@JsonProperty("url") val url: String? = null,
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
@JsonProperty("media_transformations") val mediaTransformations: Map<MimeType, Array<String>> = emptyMap()
)
fun parse(body: String): ServerInfo {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, ServerInfo::class.java)
}
suspend fun loadInfo(
baseUrl: String
): ServerInfo {
checkNotInMainThread()
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
.build()
HttpClient.getHttpClient()
.newCall(request)
.execute().use { response ->
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
return parse(body)
} else {
throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}")
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
throw e
}
}
}
}
}
typealias PlanName = String
typealias MimeType = String
@@ -0,0 +1,279 @@
package com.vitorpamplona.amethyst.service
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.InputStream
import java.util.Base64
import kotlin.coroutines.resume
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class Nip96Uploader(val account: Account?) {
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96MediaServers.ServerName,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val serverInfo = Nip96Retriever().loadInfo(
server.baseUrl
)
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
}
suspend fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
contentResolver: ContentResolver,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
}
suspend fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
alt: String?,
sensitiveContent: String?,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
checkNotInMainThread()
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("expiration", "")
.addFormDataPart("size", length.toString())
.also { body ->
alt?.let { body.addFormDataPart("alt", it) }
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
contentType?.let { body.addFormDataPart("content_type", it) }
}
.addFormDataPart(
"file",
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl)
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseResults(str)
if (!result.processingUrl.isNullOrBlank()) {
return waitProcessing(result, server, onProgress)
} else if (result.status == "success" && result.nip94Event != null) {
return result.nip94Event
} else {
throw RuntimeException("Failed to upload with message: ${result.message}")
}
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
suspend fun delete(
hash: String,
contentType: String?,
server: Nip96Retriever.ServerInfo
): Boolean {
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBuilder = Request.Builder()
nip98Header(server.apiUrl)?.let {
requestBuilder.addHeader("Authorization", it)
}
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request = requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body.use { body ->
val str = body.string()
val result = parseDeleteResults(str)
return result.status == "success"
}
} else {
throw RuntimeException("Error Uploading image: ${response.code}")
}
}
}
private suspend fun waitProcessing(
result: Nip96Result,
server: Nip96Retriever.ServerInfo,
onProgress: (percentage: Float) -> Unit
): PartialEvent {
val client = HttpClient.getHttpClient()
var currentResult = result
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
onProgress((currentResult.percentage ?: 100) / 100f)
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(result.processingUrl)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
it.body.use {
currentResult = parseResults(it.string())
}
}
}
delay(500)
}
onProgress((currentResult.percentage ?: 100) / 100f)
val nip94 = currentResult.nip94Event
if (nip94 != null) {
return nip94
} else {
throw RuntimeException("Error waiting for processing. Final result is unavailable")
}
}
suspend fun nip98Header(url: String): String? {
return withTimeoutOrNull(5000) {
suspendCancellableCoroutine { continuation ->
nip98Header(url, "POST") { authorizationToken ->
continuation.resume(authorizationToken)
}
}
}
}
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, file) {
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
data class DeleteResult(
val status: String?,
val message: String?
)
private fun parseDeleteResults(body: String): DeleteResult {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, DeleteResult::class.java)
}
data class Nip96Result(
val status: String? = null,
val message: String? = null,
@JsonProperty("processing_url")
val processingUrl: String? = null,
val percentage: Int? = null,
@JsonProperty("nip94_event")
val nip94Event: PartialEvent? = null
)
class PartialEvent(
val tags: Array<Array<String>>? = null,
val content: String? = null
)
private fun parseResults(body: String): Nip96Result {
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return mapper.readValue(body, Nip96Result::class.java)
}
}
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
@@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
// TODO: Migrate this to a property of AccountVi
object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account
var otherAccounts = listOf<HexKey>()
val latestEOSEs = EOSEAccount()
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
@@ -75,6 +77,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createOtherAccountsBaseFilter(): TypedFilter? {
if (otherAccounts.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind, AdvertisedRelayListEvent.kind, MuteListEvent.kind, PeopleListEvent.kind),
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
limit = 100
)
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -218,7 +232,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
override fun updateChannelFilters() {
return if (hasLoadedTheBasics[account.userProfile()] != null) {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
accountChannel.typedFilters = listOfNotNull(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
@@ -227,7 +241,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter()
).ifEmpty { null }
} else {
// just the basics.
@@ -44,9 +44,11 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
)
)
fun createMyChannelsFilter(): TypedFilter {
fun createMyChannelsFilter(): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
@@ -60,6 +62,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastChannelInfoFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = COMMON_FEED_TYPES, // Metadata comes from any relay
@@ -75,6 +79,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
if (followingEvents.isEmpty()) return null
return followingEvents.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
@@ -93,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
}
override fun updateChannelFilters() {
val list = listOf(
val list = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
@@ -35,8 +35,8 @@ abstract class NostrDataSource(val debugName: String) {
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
if (subscriptions.containsKey(subscriptionId)) {
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
@@ -46,6 +46,9 @@ abstract class NostrDataSource(val debugName: String) {
}
consume(event, relay)
if (afterEOSE) {
markAsEOSE(subscriptionId, relay)
}
}
}
@@ -61,12 +64,8 @@ abstract class NostrDataSource(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
relay.url
)
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptions.containsKey(subscriptionId)) {
markAsEOSE(subscriptionId, relay)
}
}
@@ -178,7 +177,7 @@ abstract class NostrDataSource(val debugName: String) {
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
if (updatedSubscription.id in currentFilters.keys) {
if (currentFilters.containsKey(updatedSubscription.id)) {
if (updatedSubscriptionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
@@ -221,6 +220,13 @@ abstract class NostrDataSource(val debugName: String) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
open fun markAsEOSE(subscriptionId: String, relay: Relay) {
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
relay.url
)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
open fun notify(relay: Relay, description: String) = Unit
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@@ -42,6 +43,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
job?.cancel()
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
),
hashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(ClassifiedsEvent.kind),
tags = mapOf(
"t" to it.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
},
geohashToLoad?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(ClassifiedsEvent.kind),
tags = mapOf(
"g" to it.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
)
)
}
)
}
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
@@ -238,16 +287,19 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
discoveryFeedChannel.typedFilters = createLiveStreamFilter()
.plus(createPublicChatFilter())
.plus(createMarketplaceFilter())
.plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsTagsFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
}
}
@@ -22,6 +22,7 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
lateinit var account: Account
@@ -33,7 +34,11 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
override fun start() {
job?.cancel()
job = account.scope.launch(Dispatchers.IO) {
job = scope.launch(Dispatchers.IO) {
// creates cache on main
withContext(Dispatchers.Main) {
account.userProfile().live()
}
account.liveHomeFollowLists.collect {
if (this@NostrHomeDataSource::account.isInitialized) {
invalidateFilters()
@@ -14,7 +14,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.round
@@ -142,8 +141,17 @@ class ZapPaymentHandler(val account: Account) {
} else {
launch(Dispatchers.IO) {
// Awaits for the event to come back to LocalCache.
delay(5000)
onProgress(1f)
var count = 0
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
count++
Thread.sleep(5000)
}
if (invoicesToPayOnIntent.isNotEmpty()) {
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
onProgress(1f)
} else {
onProgress(1f)
}
}
}
}
@@ -116,7 +116,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
@@ -4,11 +4,14 @@ import android.content.ComponentName
import android.content.Context
import android.os.Bundle
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
object PlaybackClientController {
val cache = LruCache<Int, SessionToken>(1)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun prepareController(
controllerID: String,
@@ -25,9 +28,14 @@ object PlaybackClientController {
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
var session = cache.get(context.hashCode())
if (session == null) {
session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
cache.put(context.hashCode(), session)
}
val controllerFuture = MediaController
.Builder(context, sessionTokenLocal)
.Builder(context, session)
.setConnectionHints(bundle)
.buildAsync()
@@ -1,13 +1,9 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
try {
fetch(timeOut)
@@ -17,26 +13,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
private suspend fun fetch(timeOut: Int = 30000) {
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(url, document)
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
}
}
return isImage
callback?.onComplete(getDocument(url, timeOut))
}
fun cleanUp() {
@@ -1,14 +1,16 @@
package com.vitorpamplona.amethyst.service.previews
import androidx.compose.runtime.Immutable
import okhttp3.MediaType
import java.net.URL
@Immutable
data class UrlInfoItem(
class UrlInfoItem(
val url: String = "",
val title: String = "",
val description: String = "",
val image: String = ""
val image: String = "",
val mimeType: MediaType
) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath =
@@ -18,6 +20,10 @@ data class UrlInfoItem(
image
}
fun fetchComplete(): Boolean {
return url.isNotEmpty() && image.isNotEmpty()
}
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
@@ -46,21 +48,31 @@ private val META_X_IMAGE = arrayOf(
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
suspend fun getDocument(url: String, timeOut: Int = 30000): UrlInfoItem =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
val html = HttpClient.getHttpClient().newCall(request).execute().use {
HttpClient.getHttpClient().newCall(request).execute().use {
if (it.isSuccessful) {
it.body.string()
val mimeType = it.headers.get("Content-Type")?.toMediaType()
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers.get("Content-Type")}")
if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
Jsoup.parse(html)
}
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
suspend fun parseHtml(url: String, document: Document, type: MediaType): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
@@ -106,8 +118,8 @@ suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image)
return@withContext UrlInfoItem(url, title, description, image, type)
}
}
return@withContext UrlInfoItem(url, title, description, image)
return@withContext UrlInfoItem(url, title, description, image, type)
}
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@@ -18,21 +18,47 @@ import java.util.UUID
*/
object Client : RelayPool.Listener {
private var listeners = setOf<Listener>()
private var relays = Constants.convertDefaultRelays()
private var relays = emptyArray<Relay>()
private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized
fun connect(relays: Array<Relay>) {
fun reconnect(relays: Array<Relay>?, onlyIfChanged: Boolean = false) {
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
checkNotInMainThread()
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
if (onlyIfChanged) {
if (!isSameRelaySetConfig(relays)) {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
} else {
if (this.relays.isNotEmpty()) {
RelayPool.disconnect()
RelayPool.unregister(this)
RelayPool.unloadRelays()
}
if (relays != null) {
RelayPool.register(this)
RelayPool.loadRelays(relays.toList())
RelayPool.requestAndWatch()
this.relays = relays
}
}
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
if (relays.size != newRelayConfig.size) return false
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
if (relays.size != newRelayConfig?.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
@@ -99,7 +125,7 @@ object Client : RelayPool.Listener {
@OptIn(DelicateCoroutinesApi::class)
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
relay.connectAndRun {
@@ -126,18 +152,12 @@ object Client : RelayPool.Listener {
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
RelayPool.unregister(this)
RelayPool.disconnect()
RelayPool.unloadRelays()
}
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
// 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.onEvent(event, subscriptionId, relay) }
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
}
@@ -193,8 +213,8 @@ object Client : RelayPool.Listener {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): List<String> {
return subscriptions.keys.toList()
fun allSubscriptions(): Set<String> {
return subscriptions.keys
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
@@ -205,7 +225,7 @@ object Client : RelayPool.Listener {
/**
* A new message was received
*/
open fun onEvent(event: Event, subscriptionId: String, relay: Relay) = Unit
open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit
/**
* A new or repeat message was received
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClient
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -11,7 +10,7 @@ object Constants {
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
@@ -51,13 +50,13 @@ object Constants {
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", 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 forcedRelayForSearch = arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", 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 forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
@@ -2,6 +2,8 @@ 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.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
@@ -9,13 +11,11 @@ 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 okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.lang.StringBuilder
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType {
@@ -25,25 +25,19 @@ enum class FeedType {
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
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 = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.followRedirects(true)
.followSslRedirects(true)
.build()
private val httpClient = HttpClient.getHttpClientForRelays()
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
@@ -87,7 +81,7 @@ class Relay(
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
Log.d("Client", "Relay.connect $url")
Log.d("Relay", "Relay.connect $url")
// BRB is crashing OkHttp Deflater object :(
if (url.contains("brb.io")) return
@@ -120,6 +114,7 @@ class Relay(
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
Log.d("Relay", "Connect onOpen $url $socket")
markConnectionAsReady(
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
@@ -150,6 +145,8 @@ class Relay(
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
Log.w("Relay", "Relay onClosing $url: $reason")
listeners.forEach {
it.onRelayStateChange(
this@Relay,
@@ -164,6 +161,8 @@ class Relay(
markConnectionAsClosed()
Log.w("Relay", "Relay onClosed $url: $reason")
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
}
@@ -172,7 +171,7 @@ class Relay(
errorCounter++
socket?.close(1000, "Normal close")
socket?.cancel() // 1000, "Normal close"
// Failures disconnect the relay.
markConnectionAsClosed()
@@ -185,7 +184,7 @@ class Relay(
}
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
this.afterEOSEPerSubscription.clear()
this.resetEOSEStatuses()
this.isReady = true
this.pingInMs = pingInMs
this.usingCompression = usingCompression
@@ -195,7 +194,7 @@ class Relay(
this.socket = null
this.isReady = false
this.usingCompression = false
this.afterEOSEPerSubscription.clear()
this.resetEOSEStatuses()
this.closingTimeInSeconds = TimeUtils.now()
}
@@ -209,10 +208,7 @@ class Relay(
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
listeners.forEach {
it.onEvent(this@Relay, subscriptionId, event)
if (afterEOSEPerSubscription[subscriptionId] == true) {
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
}
it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true)
}
}
"EOSE" -> listeners.forEach {
@@ -267,7 +263,7 @@ class Relay(
}
fun disconnect() {
Log.d("Client", "Relay.disconnect $url")
Log.d("Relay", "Relay.disconnect $url")
checkNotInMainThread()
closingTimeInSeconds = TimeUtils.now()
@@ -275,7 +271,11 @@ class Relay(
socket = null
isReady = false
usingCompression = false
afterEOSEPerSubscription.clear()
resetEOSEStatuses()
}
fun resetEOSEStatuses() {
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
}
fun sendFilter(requestId: String) {
@@ -284,16 +284,22 @@ class Relay(
if (read) {
if (isConnected()) {
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
val filters = Client.getSubscriptionFilters(requestId).filter { filter ->
activeTypes.any { it in filter.types }
}
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
val request = filters.joinToStringLimited(
separator = ",",
limit = 10,
prefix = """["REQ","$requestId",""",
postfix = "]"
) { it.filter.toJson(url) }
// Log.d("Relay", "onFilterSent $url $requestId $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
afterEOSEPerSubscription.clear()
resetEOSEStatuses()
}
}
} else {
@@ -306,6 +312,33 @@ class Relay(
}
}
fun <T> Iterable<T>.joinToStringLimited(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
transform: ((T) -> CharSequence)? = null
): String {
val buffer = StringBuilder()
buffer.append(prefix)
var count = 0
for (element in this) {
if (limit < 0 || count <= limit) {
if (++count > 1) buffer.append(separator)
when {
transform != null -> buffer.append(transform(element))
element is CharSequence? -> buffer.append(element)
element is Char -> buffer.append(element)
else -> buffer.append(element.toString())
}
} else {
break
}
}
buffer.append(postfix)
return buffer.toString()
}
fun sendFilterOnlyIfDisconnected() {
checkNotInMainThread()
@@ -364,7 +397,7 @@ class Relay(
checkNotInMainThread()
val msg = """["CLOSE","$subscriptionId"]"""
Log.d("Relay", "Close Subscription $url $msg")
// Log.d("Relay", "Close Subscription $url $msg")
socket?.send(msg)
}
@@ -393,7 +426,7 @@ class Relay(
/**
* A new message was received
*/
fun onEvent(relay: Relay, subscriptionId: String, event: Event)
fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean)
fun onError(relay: Relay, subscriptionId: String, error: Error)
@@ -103,7 +103,7 @@ object RelayPool : Relay.Listener {
}
interface Listener {
fun onEvent(event: Event, subscriptionId: String, relay: Relay)
fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean)
fun onError(error: Error, subscriptionId: String, relay: Relay)
@@ -116,8 +116,8 @@ object RelayPool : Relay.Listener {
fun onNotify(relay: Relay, description: String)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) {
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
}
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
@@ -25,13 +25,14 @@ 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.service.HttpClient
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
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.note.Nip47
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
@@ -182,7 +183,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy")
GlobalScope.launch(Dispatchers.IO) {
GlobalScope.launch(Dispatchers.Main) {
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
@@ -222,6 +223,14 @@ class MainActivity : AppCompatActivity() {
changedNetwork = true
}
if (changedNetwork) {
if (isOnMobileData) {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_MOBILE)
} else {
HttpClient.changeTimeouts(HttpClient.DEFAULT_TIMEOUT_ON_WIFI)
}
}
return changedNetwork
}
@@ -252,9 +261,7 @@ class MainActivity : AppCompatActivity() {
GlobalScope.launch(Dispatchers.IO) {
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}")
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
GlobalScope.launch(Dispatchers.IO) {
serviceManager.forceRestart()
}
serviceManager.forceRestart()
}
}
}
@@ -312,7 +319,7 @@ fun uriToRoute(uri: String?): String? {
}
} ?: try {
uri?.let {
Nip47.parse(it)
Nip47WalletConnectParser.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
@@ -1,283 +0,0 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.IOException
import okio.source
import java.io.InputStream
import java.util.Base64
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
class ImageUploader(val account: Account?) {
fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = when (server) {
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
NostrCheckMeServer()
}
else -> {
NostrBuildServer()
}
}
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
server.inputParameterName(contentType),
"$fileName.$extension",
object : RequestBody() {
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
}
}
)
.build()
server.authorizationToken(account, requestBody.toString()) { authorizationToken ->
if (authorizationToken != null) {
requestBuilder.addHeader("Authorization", authorizationToken)
}
requestBuilder
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
val url = server.parseUrlFromSuccess(body.string(), authorizationToken)
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
})
}
}
fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) {
val myAccount = account
if (myAccount == null) {
onReady(null)
return
}
myAccount.createHTTPAuthorization(url, method, body) {
val noteJson = it.toJson()
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
onReady("Nostr $encodedNIP98Event")
}
}
}
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String?
abstract fun inputParameterName(contentType: String?): String
open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
onReady(null)
}
}
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
}
class ImgurServer : FileServer() {
override fun postUrl(contentType: String?): String {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
onReady("Client-ID e6aea87296f3f96")
}
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
val data = tree?.get("data")
val data0 = data?.get(0)
val url = data0?.get("url")
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady)
}
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
}
class NostrCheckMeServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
checkNotInMainThread()
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("url")?.asText()
val id = tree?.get("id")?.asText()
var isCompleted = false
val client = HttpClient.getHttpClient()
val requrl =
"https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
val requestBuilder = Request.Builder().url(requrl)
if (authorizationToken != null) {
requestBuilder.addHeader("Authorization", authorizationToken)
}
val request = requestBuilder.get().build()
while (!isCompleted) {
client.newCall(request).execute().use {
val tree = jacksonObjectMapper().readTree(it.body.string())
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
return url
}
override fun inputParameterName(contentType: String?): String {
return "mediafile"
}
override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) {
ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady)
}
}
@@ -9,25 +9,29 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
data class ServerOption(
val server: Nip96MediaServers.ServerName,
val isNip95: Boolean
)
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var selectedServer by mutableStateOf<ServerOption?>(null)
var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
@@ -38,25 +42,14 @@ open class NewMediaModel : ViewModel() {
var uploadingDescription = mutableStateOf<String?>(null)
var onceUploaded: () -> Unit = {}
var onError: (String) -> Unit = {}
open fun load(account: Account, uri: Uri, contentType: String?) {
open fun load(account: Account, uri: Uri, contentType: String?, onError: (String) -> Unit) {
this.account = account
this.galleryUri = uri
this.mediaType = contentType
this.selectedServer = defaultServer()
// if (selectedServer == ServersAvailable.IMGUR) {
// selectedServer = ServersAvailable.IMGUR_NIP_94
// } else
if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
}
this.selectedServer = ServerOption(defaultServer(), false)
this.onError = onError
}
fun upload(context: Context, relayList: List<Relay>? = null) {
@@ -76,16 +69,15 @@ open class NewMediaModel : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (selectedServer == ServersAvailable.NIP95) {
if (serverToUse.isNip95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList, context)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
onError(context.getString(R.string.could_not_open_the_compressed_file))
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
@@ -95,30 +87,34 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(
imageUrl,
mimeType,
alt,
sensitiveContent,
relayList = relayList
)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = serverToUse.server,
contentResolver = contentResolver,
onProgress = { percent: Float ->
uploadingPercentage.value = 0.2f + (0.2f * percent)
}
}
)
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
relayList = relayList,
context
)
} catch (e: Exception) {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
onError(context.getString(R.string.failed_to_upload_media, e.message))
}
}
}
},
@@ -126,9 +122,7 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
onError(context.getString(R.string.error_when_compressing_media, it))
}
)
}
@@ -142,85 +136,115 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.0f
alt = ""
selectedServer = account?.defaultFileServer
selectedServer = ServerOption(defaultServer(), false)
}
fun canPost(): Boolean {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null,
context: Context
) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
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 }
val originalHash = 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 }
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading))
return
}
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
FileHeader.prepare(
imageData,
imageUrl,
mimeType,
alt,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(it, relayList) {
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
cancel()
}
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
FileHeader.prepare(
data = imageData,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) {
uploadingPercentage.value = 1.00f
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
onceUploaded()
cancel()
}
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
},
onError = {
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
}
}
)
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
isUploadingImage = false
onError(context.getString(R.string.could_not_download_from_the_server))
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String,
sensitiveContent: Boolean,
relayList: List<Relay>? = null,
context: Context
) {
if (bytes.size > 80000) {
viewModelScope.launch {
onError("Media is too big for NIP-95")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
return
}
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
alt,
sensitiveContent,
null,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f
account?.createNip95(bytes, headerInfo = it) { nip95 ->
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
uploadingDescription.value = "Sending"
uploadingPercentage.value = 0.60f
account?.sendNip95(nip95.first, nip95.second, relayList)
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
uploadingPercentage.value = 1.00f
isUploadingImage = false
@@ -233,9 +257,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.00f
isUploadingImage = false
cancel()
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
}
)
}
@@ -243,7 +265,7 @@ open class NewMediaModel : ViewModel() {
fun isImage() = mediaType?.startsWith("image")
fun isVideo() = mediaType?.startsWith("video")
fun defaultServer() = account?.defaultFileServer
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
fun onceUploaded(onceUploaded: () -> Unit) {
this.onceUploaded = onceUploaded
}
@@ -5,7 +5,6 @@ import android.net.Uri
import android.os.Build
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -46,7 +45,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -55,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -67,14 +65,8 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
postViewModel.load(account, uri, mediaType) {
accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it)
}
}
@@ -146,7 +138,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
onPost = {
onClose()
postViewModel.upload(context, relayList)
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
postViewModel.selectedServer?.let {
if (!it.isNip95) {
account.changeDefaultFileServer(it.server)
}
}
},
isActive = postViewModel.canPost()
)
@@ -170,26 +166,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
}
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 ||
selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
val fileServers = listOf(
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringResource(id = R.string.upload_server_relays_nip95)
),
true
)
)
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -218,7 +207,6 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
@@ -252,10 +240,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
placeholder = fileServers.firstOrNull {
it.server == accountViewModel.account.defaultFileServer
}?.server?.name ?: fileServers[0].server.name,
options = fileServerOptions,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
postViewModel.selectedServer = fileServers[it]
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
@@ -263,44 +253,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
)
}
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(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),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
.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),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
}
)
}
}
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -93,6 +94,7 @@ 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
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
@@ -110,8 +112,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
@@ -124,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
@@ -152,6 +155,7 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -345,6 +349,15 @@ fun NewPostView(
}
}
if (postViewModel.wantsProduct) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SellProduct(postViewModel = postViewModel)
}
}
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
@@ -392,8 +405,10 @@ fun NewPostView(
url,
accountViewModel.account.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
accountViewModel.account.changeDefaultFileServer(server)
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
if (!server.isNip95) {
accountViewModel.account.changeDefaultFileServer(server.server)
}
},
onCancel = {
postViewModel.contentToAddUrl = null
@@ -448,8 +463,7 @@ fun NewPostView(
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(myUrlPreview)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
@@ -530,6 +544,16 @@ fun NewPostView(
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
if (postViewModel.wantsPoll) {
postViewModel.wantsProduct = false
}
}
}
AddClassifiedsButton(postViewModel) {
postViewModel.wantsProduct = !postViewModel.wantsProduct
if (postViewModel.wantsProduct) {
postViewModel.wantsPoll = false
}
}
@@ -635,7 +659,11 @@ private fun MessageField(
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
text = if (postViewModel.wantsProduct) {
stringResource(R.string.description)
} else {
stringResource(R.string.what_s_on_your_mind)
},
color = MaterialTheme.colorScheme.placeholderText
)
},
@@ -776,6 +804,227 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
}
}
@Composable
fun SellProduct(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.classifieds_title),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.title = it
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.classifieds_title_placeholder),
color = MaterialTheme.colorScheme.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colorScheme.primary
),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.classifieds_price),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
runCatching {
if (it.text.isEmpty()) {
postViewModel.price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
postViewModel.price = it
}
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.classifieds_condition),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
val conditionTypes = listOf(
Triple(ClassifiedsEvent.CONDITION.NEW, stringResource(id = R.string.classifieds_condition_new), stringResource(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)),
Triple(ClassifiedsEvent.CONDITION.USED_GOOD, stringResource(id = R.string.classifieds_condition_good), stringResource(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))
)
val conditionOptions = remember { conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = {
postViewModel.condition = conditionTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp, bottom = 1.dp)
) { currentOption, modifier ->
MyTextField(
value = TextFieldValue(currentOption),
onValueChange = {},
readOnly = true,
modifier = modifier,
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.classifieds_category),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
val categoryList = listOf(
R.string.classifieds_category_clothing,
R.string.classifieds_category_accessories,
R.string.classifieds_category_electronics,
R.string.classifieds_category_furniture,
R.string.classifieds_category_collectibles,
R.string.classifieds_category_books,
R.string.classifieds_category_pets,
R.string.classifieds_category_sports,
R.string.classifieds_category_fitness,
R.string.classifieds_category_art,
R.string.classifieds_category_crafts,
R.string.classifieds_category_home,
R.string.classifieds_category_office,
R.string.classifieds_category_food,
R.string.classifieds_category_misc,
R.string.classifieds_category_other
)
val categoryTypes = categoryList.map {
Triple(it, stringResource(id = it), null)
}
val categoryOptions = remember { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() }
TextSpinner(
placeholder = categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "",
options = categoryOptions,
onSelect = {
postViewModel.category = TextFieldValue(categoryTypes[it].second)
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp, bottom = 1.dp)
) { currentOption, modifier ->
MyTextField(
value = TextFieldValue(currentOption),
onValueChange = {},
readOnly = true,
modifier = modifier,
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.classifieds_location),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.locationText,
onValueChange = {
postViewModel.locationText = it
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.classifieds_location_placeholder),
color = MaterialTheme.colorScheme.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colorScheme.primary
),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
Divider()
}
}
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
Column(
@@ -862,11 +1111,11 @@ fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewMo
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
text = stringResource(R.string.zap_split_search_and_add_user_placeholder),
color = MaterialTheme.colorScheme.placeholderText
)
},
@@ -1213,6 +1462,36 @@ private fun ForwardZapTo(
}
}
@Composable
private fun AddClassifiedsButton(
postViewModel: NewPostViewModel,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
if (!postViewModel.wantsProduct) {
Icon(
imageVector = Icons.Default.Sell,
contentDescription = stringResource(R.string.classifieds),
modifier = Modifier
.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground
)
} else {
Icon(
imageVector = Icons.Default.Sell,
contentDescription = stringResource(id = R.string.classifieds),
modifier = Modifier
.size(20.dp),
tint = BitcoinOrange
)
}
}
}
@Composable
private fun MarkAsSensitive(
postViewModel: NewPostViewModel,
@@ -1320,8 +1599,8 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
@Composable
fun ImageVideoDescription(
uri: Uri,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable, Boolean) -> Unit,
defaultServer: Nip96MediaServers.ServerName,
onAdd: (String, ServerOption, Boolean) -> Unit,
onCancel: () -> Unit,
onError: (String) -> Unit,
accountViewModel: AccountViewModel
@@ -1332,23 +1611,19 @@ fun ImageVideoDescription(
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
val fileServers = listOf(
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
ServerOption(
Nip96MediaServers.ServerName(
"NIP95",
stringResource(id = R.string.upload_server_relays_nip95)
),
true
)
)
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
var message by remember { mutableStateOf("") }
var sensitiveContent by remember { mutableStateOf(false) }
@@ -1458,10 +1733,12 @@ fun ImageVideoDescription(
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
placeholder = fileServers.firstOrNull {
it.server == accountViewModel.account.defaultFileServer
}?.server?.name ?: fileServers[0].server.name,
options = fileServerOptions,
onSelect = {
selectedServer = fileServers[it].first
selectedServer = fileServers[it]
},
modifier = Modifier
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
@@ -1469,45 +1746,41 @@ fun ImageVideoDescription(
)
}
if (isNIP94Server(selectedServer) ||
selectedServer == ServersAvailable.NIP95
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = sensitiveContent,
onCheckedChange = { sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
SettingSwitchItem(
checked = sensitiveContent,
onCheckedChange = { sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
}
)
}
Button(
@@ -18,10 +18,10 @@ import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
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.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -33,16 +33,24 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import java.net.URLEncoder
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@@ -61,6 +69,9 @@ open class NewPostViewModel() : ViewModel() {
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
@@ -94,6 +105,14 @@ open class NewPostViewModel() : ViewModel() {
var isValidConsensusThreshold = mutableStateOf(true)
var isValidClosedAt = mutableStateOf(true)
// Classifieds
var wantsProduct by mutableStateOf(false)
var title by mutableStateOf(TextFieldValue(""))
var price by mutableStateOf(TextFieldValue(""))
var locationText by mutableStateOf(TextFieldValue(""))
var category by mutableStateOf(TextFieldValue(""))
var condition by mutableStateOf<ClassifiedsEvent.CONDITION>(ClassifiedsEvent.CONDITION.USED_LIKE_NEW)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
@@ -188,7 +207,7 @@ open class NewPostViewModel() : ViewModel() {
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
forwardZapTo?.items?.map {
forwardZapTo.items.map {
ZapSplitSetup(
lnAddressOrPubKeyHex = it.key.pubkeyHex,
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
@@ -209,11 +228,25 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
nip95attachments.forEach {
if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) {
account?.sendNip95(it.first, it.second, relayList)
}
}
val urls = findURLs(tagger.message)
val usedAttachments = nip94attachments.filter {
it.urls().intersect(urls).isNotEmpty()
}
usedAttachments.forEach {
account?.sendHeader(it, relayList, { })
}
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
@@ -271,7 +304,26 @@ open class NewPostViewModel() : ViewModel() {
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
geoHash,
nip94attachments = usedAttachments
)
} else if (wantsProduct) {
account?.sendClassifieds(
title = title.text,
price = Price(price.text, "SATS", null),
condition = condition,
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
location = locationText.text,
category = category.text,
directMentions = tagger.directMentions,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments
)
} else {
// adds markers
@@ -295,7 +347,8 @@ open class NewPostViewModel() : ViewModel() {
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
geohash = geoHash,
nip94attachments = usedAttachments
)
}
}
@@ -303,7 +356,14 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
context: Context
) {
isUploadingImage = true
contentToAddUrl = null
@@ -316,34 +376,50 @@ open class NewPostViewModel() : ViewModel() {
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
}
} else {
viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n" + imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
contentResolver = contentResolver,
onProgress = { }
)
if (!isPrivate) {
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent
)
} else {
noNIP94(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent
)
}
)
} catch (e: Exception) {
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
}
}
}
},
@@ -381,6 +457,10 @@ open class NewPostViewModel() : ViewModel() {
wantsZapraiser = false
zapRaiserAmount = null
wantsProduct = false
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
price = TextFieldValue("")
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
@@ -489,7 +569,7 @@ open class NewPostViewModel() : ViewModel() {
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo?.addItem(item)
forwardZapTo.addItem(item)
forwardZapToEditting = TextFieldValue("")
/*
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
@@ -527,6 +607,7 @@ open class NewPostViewModel() : ViewModel() {
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
(!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) &&
contentToAddUrl == null
}
@@ -543,44 +624,139 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
alt,
sensitiveContent,
onReady = {
account?.sendHeader(it, relayList = relayList) { note ->
isUploadingImage = false
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean
) {
// Images don't seem to be ready immediately after upload
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 }
val originalHash = 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 }
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent())
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
return
}
urlPreview = findUrlInMessage()
}
},
onError = {
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
nip94attachments = nip94attachments + event
val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage()
}
)
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
suspend fun noNIP94(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean
) {
// Images don't seem to be ready immediately after upload
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 }
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Server failed to return a url")
}
return
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
isUploadingImage = false
val contentWarning = if (sensitiveContent) "" else null
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
fun addInlineMetadataAsNIP54(
imageUrl: String,
dim: String?,
m: String?,
alt: String?,
blurHash: String?,
x: String?,
sensitiveContent: String?
): String {
val extension = listOfNotNull(
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }
).joinToString("&")
return if (imageUrl.contains("#")) {
"$imageUrl&$extension"
} else {
"$imageUrl#$extension"
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean
) {
if (bytes.size > 80000) {
viewModelScope.launch {
imageUploadingError.emit("Media is too big for NIP-95")
isUploadingImage = false
}
return
}
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
alt,
sensitiveContent,
null,
onReady = {
account?.createNip95(bytes, headerInfo = it) { nip95 ->
val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false
@@ -605,6 +781,7 @@ open class NewPostViewModel() : ViewModel() {
contentToAddUrl = uri
}
@OptIn(ExperimentalCoroutinesApi::class)
fun startLocation(context: Context) {
locUtil = LocationUtil(context)
locUtil?.let {
@@ -57,7 +57,7 @@ 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.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -74,11 +74,11 @@ 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.Size55dp
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 kotlinx.coroutines.launch
@@ -329,7 +329,7 @@ fun ServerConfig(
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.url), it)
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
@@ -369,7 +369,12 @@ fun ServerConfigClickableLine(
modifier = Modifier.padding(vertical = 5.dp)
) {
Column(Modifier.clickable(onClick = onClick)) {
RenderRelayIcon(iconUrl = item.briefInfo.favIcon, loadProfilePicture, Size55dp)
RenderRelayIcon(
item.briefInfo.displayUrl,
item.briefInfo.favIcon,
loadProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier
)
}
Spacer(modifier = HalfHorzPadding)
@@ -32,10 +32,9 @@ class NewRelayListViewModel : ViewModel() {
relays.let {
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
clear()
}
}
clear()
}
fun loadRelayDocuments() {
@@ -120,7 +119,7 @@ class NewRelayListViewModel : ViewModel() {
fun deleteAll() {
_relays.update { relays ->
relays.dropWhile { relays.isNotEmpty() }
emptyList()
}
}
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity
@@ -123,7 +124,7 @@ class NewUserMetadataViewModel : ViewModel() {
ObjectMapper().writeValue(writer, currentJson)
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims)
}
clear()
}
@@ -184,23 +185,35 @@ class NewUserMetadataViewModel : ViewModel() {
context.applicationContext,
onReady = { fileUri, contentType, size ->
viewModelScope.launch(Dispatchers.IO) {
ImageUploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
try {
val result = Nip96Uploader(account).uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = null,
sensitiveContent = null,
server = account.defaultFileServer,
contentResolver = contentResolver,
onProgress = { }
)
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
if (url != null) {
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploaded(url)
} else {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
} catch (e: Exception) {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
}
},
onError = {
@@ -13,6 +13,7 @@ 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
@@ -26,7 +27,7 @@ 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.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
@@ -36,14 +37,14 @@ 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.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfo,
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
relayInfo: RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -81,9 +82,10 @@ fun RelayInformationDialog(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
Column() {
RenderRelayIcon(
relayBriefInfo.displayUrl,
relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
Size55dp
MaterialTheme.colorScheme.largeRelayIconModifier
)
}
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -37,12 +37,12 @@ import kotlinx.collections.immutable.toImmutableList
data class RelayList(
val relay: Relay,
val relayInfo: RelayBriefInfo,
val relayInfo: RelayBriefInfoCache.RelayBriefInfo,
val isSelected: Boolean
)
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfo,
val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
val relayInfo: RelayInformation
)
@@ -61,7 +61,7 @@ fun RelaySelectionDialog(
accountViewModel.account.activeWriteRelays().map {
RelayList(
relay = it,
relayInfo = RelayBriefInfo(it.url),
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
isSelected = preSelectedList.any { relay -> it.url == relay.url }
)
}
@@ -167,7 +167,12 @@ fun RelaySelectionDialog(
accountViewModel.retrieveRelayDocument(
item.relay.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
relayInfo = RelayInfoDialog(
RelayBriefInfoCache.RelayBriefInfo(
item.relay.url
),
it
)
},
onError = { url, errorCode, exceptionMessage ->
val msg = when (errorCode) {
@@ -1,17 +1,22 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -21,7 +26,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -34,21 +38,39 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CashuIcon
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
var cachuData by remember {
mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading())
}
LaunchedEffect(key1 = cashutoken) {
launch(Dispatchers.IO) {
@@ -59,7 +81,7 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
}
}
Crossfade(targetState = cachuData) {
Crossfade(targetState = cachuData, label = "CashuPreview(") {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> -> Text(
@@ -73,26 +95,58 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
@Composable
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
val lud16 = remember(accountViewModel) {
accountViewModel.account.userProfile().info?.lud16
}
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
}
val useWebService = false
@Composable
@Preview()
fun CashuPreviewPreview() {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
Column() {
CashuPreview(
token = CashuToken("token", "mint", 32400, TextNode("")),
melt = { token, context, onDone ->
},
toast = { title, message ->
}
)
CashuPreviewNew(
token = CashuToken("token", "mint", 32400, TextNode("")),
melt = { token, context, onDone ->
},
toast = { title, message ->
}
)
}
}
}
@Composable
fun CashuPreview(
token: CashuToken,
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
toast: (String, String) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -103,7 +157,7 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
Icon(
painter = painterResource(R.drawable.cashu),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = Color.Unspecified
)
@@ -117,80 +171,195 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
Divider()
token.totalAmount.let {
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
)
Row(
modifier = Modifier
.padding(top = 5.dp)
.fillMaxWidth()
) {
var isRedeeming by remember {
mutableStateOf(false)
}
Button(
onClick = {
isRedeeming = true
melt(token, context) { title, message ->
toast(title, message)
isRedeeming = false
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
if (isRedeeming) {
LoadingAnimation()
} else {
ZapIcon(Size20Modifier, tint = Color.White)
}
Spacer(DoubleHorzSpacer)
Text(
stringResource(id = R.string.cashu_redeem_to_zap),
color = Color.White,
fontSize = 16.sp
)
}
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
CashuIcon(Size20Modifier)
Spacer(DoubleHorzSpacer)
Text(stringResource(id = R.string.cashu_redeem_to_cashu), color = Color.White, fontSize = 16.sp)
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
// Copying the token to clipboard
clipboardManager.setText(AnnotatedString(token.token))
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
CopyIcon(Size20Modifier, Color.White)
Spacer(DoubleHorzSpacer)
Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
}
Spacer(modifier = StdHorzSpacer)
}
}
}
@Composable
fun CashuPreviewNew(
token: CashuToken,
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
toast: (String, String) -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.cashu),
null,
modifier = Modifier.size(13.dp),
tint = Color.Unspecified
)
Text(
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
text = stringResource(R.string.cashu),
fontSize = 12.sp,
modifier = Modifier.padding(start = 5.dp, bottom = 1.dp)
)
}
Row(
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 20.sp
)
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Button(
Row(modifier = Modifier.padding(top = 5.dp)) {
var isRedeeming by remember {
mutableStateOf(false)
}
modifier = Modifier
.padding(vertical = 10.dp).padding(horizontal = 2.dp),
FilledTonalButton(
onClick = {
if (lud16 != null) {
scope.launch(Dispatchers.IO) {
CashuProcessor().melt(
token,
lud16,
onSuccess = { title, message ->
accountViewModel.toast(title, message)
},
onError = { title, message ->
accountViewModel.toast(title, message)
},
context
)
}
} else {
accountViewModel.toast(
context.getString(R.string.no_lightning_address_set),
context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, accountViewModel.account.userProfile().toBestDisplayName())
)
isRedeeming = true
melt(token, context) { title, message ->
toast(title, message)
isRedeeming = false
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
shape = SmallishBorder
) {
if (isRedeeming) {
LoadingAnimation()
} else {
ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground)
}
Spacer(StdHorzSpacer)
Text(
stringResource(R.string.cashu_redeem),
color = Color.White,
fontSize = 18.sp
"Redeem",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp
)
}
Button(
modifier = Modifier
.padding(vertical = 10.dp).padding(horizontal = 1.dp),
Spacer(modifier = StdHorzSpacer)
FilledTonalButton(
onClick = {
if (useWebService) {
// In case we want to use the cashu.me webservice
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=false"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} else {
// Copying the token to clipboard for now
clipboardManager.setText(AnnotatedString(token.token))
} catch (e: Exception) {
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
shape = SmallishBorder,
contentPadding = PaddingValues(0.dp)
) {
Text("", color = Color.White, fontSize = 18.sp)
OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
}
Spacer(modifier = StdHorzSpacer)
FilledTonalButton(
onClick = {
// Copying the token to clipboard
clipboardManager.setText(AnnotatedString(token.token))
},
shape = SmallishBorder,
contentPadding = PaddingValues(0.dp)
) {
CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
}
}
}
@@ -41,7 +41,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.Nip30CustomEmoji
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -550,7 +550,7 @@ fun CreateClickableTextWithEmoji(
}
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
return NIP30Parser().buildArray(text).map {
return Nip30CustomEmoji().buildArray(text).map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)
@@ -12,17 +12,18 @@ import androidx.compose.ui.text.AnnotatedString
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
val doubleCheckedUrl = remember(url) {
if (url.contains("://")) url else "https://$url"
}
val text = remember(urlText) {
AnnotatedString(urlText)
}
ClickableText(
text = text,
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
onClick = {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary)
)
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -78,15 +79,17 @@ fun ExpandableRichTextViewer(
}
Box {
RichTextViewer(
text,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav
)
Crossfade(text, label = "ExpandableRichTextViewer") {
RichTextViewer(
it,
canPreview,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
nav
)
}
if (content.length > whereToCut && !showFullText) {
Row(
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
@@ -36,6 +35,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import kotlinx.coroutines.Dispatchers
@@ -101,14 +101,14 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -119,7 +119,7 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = Color.Unspecified
)
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -37,6 +36,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -94,7 +94,7 @@ fun InvoiceRequest(
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = Color.Unspecified
)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -10,6 +11,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import kotlinx.collections.immutable.persistentListOf
@Composable
fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
@@ -42,7 +45,17 @@ fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewMo
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
if (state.previewInfo.mimeType.type == "image") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(ZoomableUrlImage(url), persistentListOf(), roundedCorner = true, accountViewModel)
}
} else if (state.previewInfo.mimeType.type == "video") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(ZoomableUrlVideo(url), persistentListOf(), roundedCorner = true, accountViewModel)
}
} else {
UrlPreviewCard(url, state.previewInfo)
}
}
else -> {
@@ -93,7 +93,7 @@ class MarkdownParser {
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "![]($word) "
} else {
@@ -69,11 +69,16 @@ class MediaCompressor {
}
}
)
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) {
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
default(width = 640, format = Bitmap.CompressFormat.JPEG)
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif") && !contentType.contains("svg")) {
try {
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
default(width = 640, format = Bitmap.CompressFormat.JPEG)
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
e.printStackTrace()
onReady(uri, contentType, null)
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} else {
onReady(uri, contentType, null)
}
@@ -30,17 +30,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.service.InvoiceSegment
import com.vitorpamplona.amethyst.service.LinkSegment
import com.vitorpamplona.amethyst.service.PhoneSegment
import com.vitorpamplona.amethyst.service.RegularTextSegment
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
import com.vitorpamplona.amethyst.service.Segment
@@ -95,6 +97,22 @@ val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
return if (fullUrl.contains("?")) {
fullUrl.split("?")[0].lowercase()
} else if (fullUrl.contains("#")) {
fullUrl.split("#")[0].lowercase()
} else {
fullUrl.lowercase()
}
}
fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean {
return try {
URL(url).toURI()
@@ -160,59 +178,59 @@ private fun RenderRegular(
)
}
MeasureSpaceWidth() { spaceWidth ->
Column() {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
val spaceWidth = measureSpaceWidth(textStyle)
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
Column() {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
) {
paragraph.words.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
) {
paragraph.words.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
@@ -222,17 +240,15 @@ private fun RenderRegular(
}
@Composable
fun MeasureSpaceWidth(
content: @Composable (measuredWidth: Dp) -> Unit
) {
SubcomposeLayout { constraints ->
val measuredWidth = subcompose("viewToMeasure", { Text(" ") })[0].measure(Constraints()).width.toDp()
fun measureSpaceWidth(textStyle: TextStyle): Dp {
val fontFamilyResolver = LocalFontFamilyResolver.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val contentPlaceable = subcompose("content") {
content(measuredWidth)
}[0].measure(constraints)
layout(contentPlaceable.width, contentPlaceable.height) {
contentPlaceable.place(0, 0)
return remember(fontFamilyResolver, density, layoutDirection, textStyle) {
val widthPx = TextMeasurer(fontFamilyResolver, density, layoutDirection, 1).measure(" ", textStyle).size.width
with(density) {
widthPx.toDp()
}
}
}
@@ -334,6 +350,13 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
)
}
val markdownParseOptions = MarkdownParseOptions(
autolink = true,
isImage = { url ->
isImageOrVideoUrl(url)
}
)
@Composable
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
@@ -350,14 +373,22 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
}
ProvideTextStyle(MarkdownTextStyle) {
Material3RichText(
style = MaterialTheme.colorScheme.markdownStyle
) {
Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) {
RefreshableContent(content, tags, accountViewModel) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
onLinkClicked = onClick
markdownParseOptions = markdownParseOptions,
onLinkClicked = onClick,
onMediaCompose = { title, destination ->
ZoomableContentView(
content = remember(destination) {
RichTextParser().parseMediaUrl(destination)
?: ZoomableUrlImage(url = destination)
},
roundedCorner = true,
accountViewModel = accountViewModel
)
}
)
}
}
@@ -557,7 +588,7 @@ private fun RenderHashtag(
) {
val primary = MaterialTheme.colorScheme.primary
val background = MaterialTheme.colorScheme.onBackground
val hashtagIcon = remember(segment.hashtag) {
val hashtagIcon: HashtagIcon? = remember(segment.hashtag) {
checkForHashtagWithIcon(segment.hashtag, primary)
}
@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
@@ -14,308 +11,48 @@ import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.utils.Robohash
import okio.Buffer
private fun toHex(color: Color): String {
val argb = color.toArgb()
val rgb = argb and 0x00FFFFFF // Mask out the alpha channel
return String.format("#%06X", rgb)
}
private fun byteMod10(byte: Byte): Int {
val ub = byte.toUByte().toInt()
return ub % 10
}
private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
return Color(b1.toUByte().toInt(), b2.toUByte().toInt(), b3.toUByte().toInt())
}
private fun svgString(msg: String): String {
checkNotInMainThread()
val hash = CryptoUtils.sha256(msg.toByteArray())
val hashHex = hash.toHexKey()
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
val bodyIndex = byteMod10(hash[6])
val faceIndex = byteMod10(hash[7])
val eyesIndex = byteMod10(hash[8])
val mouthIndex = byteMod10(hash[9])
val accIndex = byteMod10(hash[10])
val body = bodies[bodyIndex]
val face = faces[faceIndex]
val eye = eyes[eyesIndex]
val mouth = mouths[mouthIndex]
val accessory = accessories[accIndex]
return """<svg id="$hashHex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs>
<style>
.cls-bg{fill:${toHex(bgColor)}}.cls-fill-1{fill:${toHex(fgColor)};}.cls-fill-2{fill:${toHex(fgColor)};}
${body.style}${face.style}${eye.style}${mouth.style}${accessory.style}
</style>
</defs>
<title>Robohash $hashHex</title>
${background}${body.paths}${face.paths}${eye.paths}${mouth.paths}${accessory.paths}
</svg>"""
}
import java.nio.charset.Charset
@Stable
class HashImageFetcher(
private val context: Context,
private val isLightTheme: Boolean,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): SourceResult {
checkNotInMainThread()
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
val buffer = Buffer()
buffer.writeString(Robohash.assemble(data.toString(), isLightTheme), Charset.defaultCharset())
buffer
} finally {
}
return SourceResult(
source = ImageSource(source, context),
mimeType = null,
mimeType = "image/svg+xml",
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
return HashImageFetcher(options.context, options.parameters.value("isLightTheme") ?: true, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest {
object RobohashImageRequest {
fun build(context: Context, message: String, isLightTheme: Boolean): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.data(message)
.fetcherFactory(HashImageFetcher.Factory)
.setParameter("isLightTheme", isLightTheme)
.addHeader("Cache-Control", "max-age=31536000")
.build()
}
}
@Immutable
private data class Part(val style: String, val paths: String)
private const val background = """<polyline class="cls-bg" points="150.3 7.4 55.1 97.9 55.1 203.1 150.3 293.6 245.9 203.1 245.9 97.9 150.3 7.4"/>"""
private val accessories: List<Part> = listOf(
Part(
""".cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""",
"""<g id="accessory-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M141.5,79.5s-1,11-1,17v8s-11,3-15,3h-10s-1-11-1-15-2-10,6-13a138,138,0,0,1,14-1C140.5,78.5,141.5,79.5,141.5,79.5Z"/><path class="cls-00-2" d="M141.5,79.5s-1,11-1,17v8s-11,3-15,3h-10s-1-11-1-15-2-10,6-13a138,138,0,0,1,14-1C140.5,78.5,141.5,79.5,141.5,79.5Z"/><path class="cls-00-3" d="M116.5,106.5s22-2.67,24-3.33v1.33s-11.42,2.74-13.21,2.87-11.79.13-11.79.13v-.94l1-.06"/><circle cx="137.5" cy="100.5" r="1"/><circle cx="139" cy="81" r="0.75"/><circle cx="117" cy="103" r="0.75"/><path class="cls-00-4" d="M117,88h3a3.49,3.49,0,0,1,2-1c1,0,9.5-1.5,9.5-1.5s-9,7-10,13v4l7-1s-1-6,2-10a22.28,22.28,0,0,1,7-6v-3l-1-1-18,2-1,1Z"/></g>"""
),
Part(
""".cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""",
"""<g id="accessory-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M103.5,165.5s11-4,15-6a59.46,59.46,0,0,1,7-3s5,0,6,3,0,5-1,6-23,8-23,8a4.45,4.45,0,0,1-5-2C99.5,166.5,103.5,165.5,103.5,165.5Z"/><path class="cls-01-2" d="M102,167c-1.07,1.3,1.29,6.38,2.64,6.19s6.07.81,4.36-4.12C107.18,165,103.05,165.72,102,167Z"/><path class="cls-01-3" d="M103.5,165.5s11-4,15-6a59.46,59.46,0,0,1,7-3s5,0,6,3,0,5-1,6-23,8-23,8a4.45,4.45,0,0,1-5-2C99.5,166.5,103.5,165.5,103.5,165.5Z"/><path class="cls-01-4" d="M106.5,173.5c3-1,3-3,2-5-3-4-6-2-6-2.24"/><path class="cls-01-4" d="M109.1,163.4s4.4.1,5.4,3.1a5.22,5.22,0,0,1-.78,5"/><circle cx="128" cy="161" r="1"/></g>"""
),
Part(
""".cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""",
"""<g id="accessory-03"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M122.5,94.5s4,3,13,3,16-6,16-6a26.39,26.39,0,0,0-1-7c-1-3-10.49-2.87-15.74-1.93s-11.26.93-12.26,3.93Z"/><ellipse class="cls-02-2" cx="133" cy="26" rx="7.5" ry="6.5"/><path class="cls-02-3" d="M140,25c.19.68-5.51,8.12-11.57,6.27a8.65,8.65,0,0,0,7.91.53C141,30,140,25,140,25Z"/><ellipse class="cls-02-4" cx="130.22" cy="22.93" rx="4.46" ry="2.23" transform="matrix(0.87, -0.5, 0.5, 0.87, 5.98, 68.18)"/><ellipse class="cls-02-5" cx="133" cy="26" rx="7.5" ry="6.5"/><path class="cls-02-6" d="M135.5,82.5s-13,1-13,4,9,4,13,4,15-2,15-6S135.5,82.5,135.5,82.5Z"/><path class="cls-02-7" d="M122.5,94.5s4,3,13,3,16-6,16-6a26.39,26.39,0,0,0-1-7c-1-3-10.49-2.87-15.74-1.93s-11.26.93-12.26,3.93Z"/><path class="cls-02-8" d="M148.19,93.77c.31-1.27-.69-6.27-.69-6.27s-7.41-2.59-8.2-2.3,1.86-.8,2-1.75.23-1.43.23-1.43,3.12,0,5.53.25,3.41,1.47,3.41,2.35l.64,2.58.36,4.29-3,2"/><path class="cls-02-9" d="M133,29.24s-7.5,13.21-6.5,28.88a245.78,245.78,0,0,0,3,26.43s0,2,4,2a14,14,0,0,0,7-2,3.4,3.4,0,0,0,1-2c0-1-11-18.6-8-48.94C133.5,33.64,134.5,27.77,133,29.24Z"/><path class="cls-02-10" d="M130.5,46.5a94.41,94.41,0,0,0,2,18,145.29,145.29,0,0,0,6,19l.52,1.8s2.48-.8,2.48-2.8c0,0-6.45-14.41-7.23-22.71S132.5,39.5,133.5,33.5c0,0-.08-3.33.21-2.91S130,38,130.5,46.5Z"/><path class="cls-02-11" d="M133,29.24s-7.5,13.21-6.5,28.88a245.78,245.78,0,0,0,3,26.43s0,2,4,2a14,14,0,0,0,7-2,3.4,3.4,0,0,0,1-2c0-1-11-18.6-8-48.94C133.5,33.64,134.5,27.77,133,29.24Z"/><path class="cls-02-7" d="M126,46s-9,1-9,6,10,5,13,5,12-1,12-6-8-5-8-5"/></g>"""
),
Part(
""".cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""",
"""<g id="accessory-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M136.5,83.5c-3.39.31-11,2-13,4a4.38,4.38,0,0,0-1,3v5a18.26,18.26,0,0,0,13,3c8-1,16-6,16-6s0-6-1-7-1-2-5-2S138.74,83.3,136.5,83.5Z"/><path class="cls-03-2" d="M125.7,86.16s-2.2,1.34-1.2,3.34,7,3,12,2a53.37,53.37,0,0,0,11-4s3.57-2.09,1.29-3.54S131.89,82.83,125.7,86.16Z"/><path class="cls-03-3" d="M125.7,86.16s-2.2,1.34-1.2,3.34,7,3,12,2A55.23,55.23,0,0,0,145.11,89c-1.11-2-8.42-5.89-8.42-5.89S129.47,84.13,125.7,86.16Z"/><path class="cls-03-4" d="M136.5,83.5c-3.39.31-11,2-13,4a4.38,4.38,0,0,0-1,3v5a18.26,18.26,0,0,0,13,3c8-1,16-6,16-6s0-6-1-7-1-2-5-2S138.74,83.3,136.5,83.5Z"/><path class="cls-03-5" d="M134.5,73.5s-5,1-5,6,1,7,1,7a18.58,18.58,0,0,0,8,0c4-1,5-3,5-3S142.5,73.5,134.5,73.5Z"/><path class="cls-03-6" d="M139.5,75.5s2,3,1,6-6,5-6,5,8-1,9-3S140.5,76.5,139.5,75.5Z"/><path class="cls-03-4" d="M134.5,73.5s-5,1-5,6,1,7,1,7a18.58,18.58,0,0,0,8,0c4-1,5-3,5-3S142.5,73.5,134.5,73.5Z"/><path class="cls-03-7" d="M144.5,88.5c1,1,0,7.3,0,7.3l7-3.3s0-6.14-1.5-7.57C148,88,147,88,144.5,88.5Z"/><path class="cls-03-8" d="M133.5,75.5a1,1,0,0,1,1,1c0,1,0,6-2,6s-3,0-2-4S133.5,75.5,133.5,75.5Z"/></g>"""
),
Part(
""".cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""",
"""<g id="accessory_05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M139.5,51.5s-24-2-32,33c0,0-6,25,20,25s32-17,34-32S149.5,51.5,139.5,51.5Z"/><path class="cls-04-2" d="M139.5,51.5c-15.88.08-26.77,10.62-32,33,0,0-5,26,20,25s34-18,34-32C161.5,60.5,149.5,51.5,139.5,51.5Z"/><path class="cls-04-3" d="M143.5,53.5s14,7,10,29-26,25-33,24-18-7-10.34-30.84C114.5,59.5,130.5,47.5,143.5,53.5Z"/><path class="cls-04-4" d="M121.5,63.5c-3,1.87-8,6-9,11s-1,14,0,16,4,1,4-2,0-17,5-21S123.4,62.32,121.5,63.5Z"/><path class="cls-04-4" d="M128.5,57.5s-6,4-3,5,6-3,6-3S133.5,56.5,128.5,57.5Z"/><path class="cls-04-5" d="M125.5,71.5s-7-2-10,1c-1.88,2.07,0,8,5,10s8-1,9-4S127.5,71.5,125.5,71.5Z"/><polygon class="cls-04-6" points="131 79 135 81 134.62 78.67 130.38 76.63 130.06 78.6 131 79"/><path class="cls-04-3" d="M130.09,76.5l4.46,2s.89,2,0,3a4.07,4.07,0,0,1-2.67,1L129,80.79A5.22,5.22,0,0,0,130.09,76.5Z"/><ellipse class="cls-04-4" cx="120" cy="75.5" rx="2.5" ry="2"/><path class="cls-04-7" d="M123.5,71.5s5,2,4,6-6.49,4.82-8.25,4.41,7.54,3.33,9.89-2.54S127,72,123.5,71.5Z"/></g>"""
),
Part(
""".cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""",
"""<g id="accessory-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M125.5,157.5s8-4,15-3,20,8,26,5l8-4s-3,13-14,14-29-3-35-2-25,7-30,6-10-1-12-11c0,0-1-3,3,0s11,4,18-1S119.5,153.5,125.5,157.5Z"/><path class="cls-05-2" d="M125.5,157.5s8-4,15-3,20,8,26,5l8-4s-3,13-14,14-29-3-35-2-25,7-30,6-10-1-12-11c0,0-1-3,3,0s11,4,18-1S119.5,153.5,125.5,157.5Z"/><ellipse cx="107.5" cy="165.25" rx="1" ry="1.25"/><ellipse cx="99.5" cy="168.25" rx="1" ry="1.25"/><ellipse cx="91.75" cy="168.94" rx="0.75" ry="0.94"/><ellipse cx="115.5" cy="163.25" rx="1" ry="1.25"/><ellipse cx="134.5" cy="162.25" rx="1" ry="1.25"/><ellipse cx="145.5" cy="163.25" rx="1" ry="1.25"/><ellipse cx="154.5" cy="164.25" rx="1" ry="1.25"/><ellipse cx="161.5" cy="163.25" rx="1" ry="1.25"/><circle class="cls-05-3" cx="125" cy="162" r="2.5"/><circle cx="125" cy="162" r="0.5"/></g>"""
),
Part(
""".cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""",
"""<g id="accessory-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M131,59s-13,1-14,6a2.62,2.62,0,0,0,.5,2.5c1,1,3,5,4,8s2,5,0,5-8,2-10,4-1,5-1,5l1,9a33.15,33.15,0,0,0,18,5c16.5.5,34-9,35-10s-1-11-1-11c.16-.17-2.84-5.17-4-5-1,.14-12,0-12,0s0-15-1-16S141.5,58.5,131,59Z"/><path class="cls-06-2" d="M151.5,88.5s1.14,9.54.57,10.77c0,0,12.43-4.77,12.43-5.77s0-9-1-11a27.36,27.36,0,0,0-4-5s1.59,1.44-.21,3.72-5.36,6.31-8.08,6.3Z"/><path class="cls-06-3" d="M131,59s-13,1-14,6a2.62,2.62,0,0,0,.5,2.5c1,1,3,5,4,8s2,5,0,5-8,2-10,4-1,5-1,5l1,9s6,5,18,5c18,0,34-9,35-10s-1-11-1-11-3-4.79-4-5-12-.18-12,0c0,0,0-15-1-16S141.5,58.5,131,59Z"/><path class="cls-06-4" d="M121.5,80.5s-7.3,1.22-8.65,3.11.21,5.68,3.93,6.29,12.31,2.13,27-.63l7.42-1.75-10.71-4A38.39,38.39,0,0,0,121.5,80.5Z"/><path class="cls-06-3" d="M132.5,67.5s15-1,14-5-14-3.56-14-3.56S118,60,117.26,64.25,132.5,67.5,132.5,67.5Z"/><path class="cls-06-5" d="M121.5,75.5s0,2,6,3,13-2,16-4,3.33-3.41,3.33-3.41l.67,6.41a10.67,10.67,0,0,1-8,6c-13,3-16.91-3.58-16.91-3.58Z"/><path class="cls-06-2" d="M137,67l3.87,16.18S147,80,147,77s0-13.73,0-13.73S145.09,67,137,67Z"/><path class="cls-06-4" d="M136.18,59l.82,8s-18,2-20-2S130.36,58,136.18,59Z"/><path class="cls-06-6" d="M113.16,83.3s-6.66,9.2,23.18,7c25.17-1.75,23.8-12.39,23.8-12.39l3.36,4.64s2,10,1,11-18,10-33.68,10a45.47,45.47,0,0,1-16.46-3.35,7.75,7.75,0,0,1-2.86-1.61l-1.2-10A5.34,5.34,0,0,1,113.16,83.3Z"/></g>"""
),
Part(
""".cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""",
"""<g id="accessory-08"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5s-1-1-6-1S131.5,84.5,128.5,86.5Z"/><path class="cls-fill-1" d="M173.5,21.5l-35,17s-.57,2.41.22,3.71l.78,1.29,37-18S178.5,20.5,173.5,21.5Z"/><path class="cls-fill-1" d="M96.5,58.5s-2,2-1,3,3,0,3,0l33.66-15.9a6.44,6.44,0,0,1-.66-3.1Z"/><path class="cls-fill-1" d="M134.5,47.5l-1,37a4.33,4.33,0,0,0,4,2,7.65,7.65,0,0,0,5-2l-2-40S135.5,44.5,134.5,47.5Z"/><path class="cls-fill-1" d="M136.5,38.5l-4,2a3.76,3.76,0,0,0-.81,1.55,5.34,5.34,0,0,0-.19,1.45c0,2,2,4,3,4,0,0-.17-.54,1.42-1.77a8.26,8.26,0,0,1,4.32-1.22h.26s-2-2-2-3v-3Z"/></g><path class="cls-07-2" d="M134.5,47.5l-1,37a4.33,4.33,0,0,0,4,2,7.65,7.65,0,0,0,5-2l-2-40S135.5,44.5,134.5,47.5Z"/><path class="cls-07-3" d="M128.5,87.5s0,2,6,2,11-1,13-3c.48-.55.5-1.75-2.25-1.87-1,0-3.15,0-3.15,0a6.77,6.77,0,0,1-4.1,1.8c-3.5.1-4.31-1.6-4.31-1.6S128.5,85.5,128.5,87.5Z"/><path class="cls-07-2" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5s-.8-1-5.8-1a7.15,7.15,0,0,1-4.57,2c-1.63,0-4.17,0-4.63-1.72A10.71,10.71,0,0,0,128.5,86.5Z"/><path class="cls-07-4" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5c-.63-.65-1.9-1.15-6-1a6.16,6.16,0,0,1-4.17,1.8c-3.83.2-4.41-1.46-4.41-1.46S129.5,85.5,128.5,86.5Z"/><path class="cls-07-2" d="M136.5,38.5l-4,2a3.76,3.76,0,0,0-.81,1.55,5.34,5.34,0,0,0-.19,1.45c0,2,2,4,3,4,0,0-.17-.54,1.42-1.77a8.26,8.26,0,0,1,4.32-1.22h.26s-2-2-2-3v-3Z"/><path class="cls-07-5" d="M96.5,58.5s-2,2-1,3,3,0,3,0l33.8-15.6a7.45,7.45,0,0,1-.8-3.4Z"/><path class="cls-07-5" d="M173.5,21.5l-35,17s-.57,2.41.22,3.71l.78,1.29,37-18C177.52,25.52,179.49,20.53,173.5,21.5Z"/><circle class="cls-07-6" cx="175.5" cy="23.5" r="2"/><path d="M137.5,5.5s-2,0,1,2,32,15,32,15l2-1-33-15Z"/><path d="M56.72,44a1.7,1.7,0,0,0,1.2,1.19L96.29,58.83l1.89-1.2L58.81,44.1S56.44,43,56.72,44Z"/><path d="M114.5,17.5s-2,0,1,2,32,15,32,15l2-1-33-15Z"/><path d="M102,26.53s-2,.12,1.12,1.94S133,40.2,133,40.2l1.91-.77-30.83-12Z"/><path d="M177.5,24.19,212,41c1,1,.39,1.37-2,1L176.5,25.5S177,25.38,177.5,24.19Z"/><path d="M154.91,35.41l35.6,14.34S192,51,188.58,50.88L154,36.79Z"/><polygon points="102.89 59.02 134.34 68.69 133.65 69.98 101 59.98 102.89 59.02"/><path d="M142,42.54l35.55,13c2.4.9.47,2.47-2,1.19L140.2,43.79Z"/><path class="cls-07-7" d="M136,38.75a5.37,5.37,0,0,0-.5,2.75c0,2,2.22,3.37,2.22,3.37l1.8,40.84L144,88.34v4l4.46-1.89v-5a35.36,35.36,0,0,0-6-1l-2-40a3.7,3.7,0,0,1-2-3v-3Z"/><path class="cls-07-8" d="M119.5,47.5l-32-12s-3-1-4,1,0,3,2,4,27,10,27,10"/><line class="cls-07-8" x1="116.05" y1="49.09" x2="83.95" y2="37.51"/><line class="cls-07-9" x1="125.5" y1="49.2" x2="134.36" y2="52.57"/><line class="cls-07-10" x1="117.9" y1="52.66" x2="134" y2="59"/><line class="cls-07-10" x1="121.42" y1="50.93" x2="134.27" y2="55.83"/><path class="cls-07-10" d="M141,54.57,158,62s4,2,3,4-5,1-6,1-13.65-5.5-13.65-5.5"/><line class="cls-07-9" x1="141.18" y1="58.12" x2="160.5" y2="66.5"/></g>"""
),
Part(
""".cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""",
"""<g id="accessory-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M135,83s-13,2-13,5a54.33,54.33,0,0,0,.5,6.5s4,4,13,3a37.83,37.83,0,0,0,16-6v-5l-3-4S143.5,81.5,135,83Z"/><path class="cls-08-2" d="M135,83s-13,2-13,5a54.33,54.33,0,0,0,.5,6.5s4,4,13,3a37.83,37.83,0,0,0,16-6v-5l-3-4S143.5,81.5,135,83Z"/><path class="cls-08-3" d="M123.5,88.5s2,3,10,2,14-3,15-5,1.19-2.37-1.41-3.18-13.22.86-16.41,1.52S122.5,85.5,123.5,88.5Z"/><path class="cls-08-4" d="M123.5,88.5s2,3,10,2a47.41,47.41,0,0,0,11.86-2.78c-.38.16-4.11-2.4-4.11-2.4s1.43-3.14,2-3.18a105.65,105.65,0,0,0-12.56,1.69C127.5,84.5,122.5,85.5,123.5,88.5Z"/><path class="cls-08-5" d="M139.5,66.5s-4-16,3-27c0,0-2-7-6-3,0,0-4,3-5,15a66,66,0,0,0,2,22S138.5,73.5,139.5,66.5Z"/><path class="cls-08-6" d="M119.5,36.5s1,13,3,19,4,11,6,16,5,7,6,5,0-3-2-10-5-18-5-25v-7a10.34,10.34,0,0,0-4-1C121.5,33.5,119.5,34.5,119.5,36.5Z"/><path class="cls-08-5" d="M110.5,50.5s8,8,10,13,5,13,6,10-3-18-6-22a61.22,61.22,0,0,0-5-6S110.5,45.5,110.5,50.5Z"/><path class="cls-08-7" d="M136.5,86.5s-5-19-19-28-20-7-20-7-4,3,1,6,12,5,18,10,12,14,12,16S131.5,88.5,136.5,86.5Z"/><path class="cls-08-7" d="M134.5,73.5s6-25,16-32,9,2,9,2v4s-4-2-8,5-9,19-9,23v8s-4,5-6,3c0,0-3.24-7.59-3.12-8.3S134.5,73.5,134.5,73.5Z"/><path class="cls-08-8" d="M144.36,88.12v7.11l7.14-3.73.07-4.64L150,84A11.59,11.59,0,0,1,144.36,88.12Z"/></g>"""
),
Part(
""".cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""",
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M122.5,88.5v6s3,4,13,3c12.5-.5,16-5,16-5v-6l-2-3a63.26,63.26,0,0,0-15,0C126.5,84.5,122.5,85.5,122.5,88.5Z"/><path class="cls-09-2" d="M122.5,88.5v6s2,3,13,3,16-5,16-5v-6l-2-3a63.26,63.26,0,0,0-15,0C126.5,84.5,122.5,85.5,122.5,88.5Z"/><path class="cls-09-3" d="M142.5,89.5c.2-.05,1.28-.44,1.5-.5-.63-1.83-4.53-5.64-5.5-5.7-7-.41-11.66,1-13.22,2-1.78,1.16-3.78,2.16,1.22,4.16C129.5,90.5,138.5,90.5,142.5,89.5Z"/><path class="cls-09-2" d="M142.5,89.5c4-1,10.83-4.83,3.92-5.92s-19.36.59-21.14,1.75-3.78,2.16,1.22,4.16C129.5,90.5,138.5,90.5,142.5,89.5Z"/><path class="cls-09-4" d="M130.5,52.5,128.24,84s1.26,3.47,7.26,2.47,7.33-3.44,7.33-3.44L132.5,54.5S131.5,51.5,130.5,52.5Z"/><path class="cls-09-5" d="M131.5,55.5l7.06,30.26s3.94-.26,3.94-2.26-10-29-10-29S130.5,50.5,131.5,55.5Z"/><path class="cls-09-2" d="M130.5,52.5,128.24,84s1.26,3.47,7.26,2.47,7.33-3.44,7.33-3.44L132.5,54.5S131.5,51.5,130.5,52.5Z"/><path class="cls-09-6" d="M144.09,88.72v8l7.41-4.26v-6l-2-3C150,85.88,147.82,87.51,144.09,88.72Z"/>"""
)
)
private val bodies: List<Part> = listOf(
Part(
""".cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""",
"""<g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-fill-1" d="M226.5,236.5s-6-10-26-12-56-2-81,13-43,44-43,65h115s3-31,22-53c0,0,8.88-7.45,14.44-9.22C227.94,240.28,228.5,239.5,226.5,236.5Z"/><path class="cls-fill-1" d="M286.5,302.5s4-50-16-59-44-8-57,6-22,43-22,53Z"/></g><path class="cls-10-2" d="M91.18,264s5.32.48,3.32,9.48-5,7-7,17-2.5,12-2.5,12H76.5S75.85,285.54,91.18,264Z"/><path class="cls-10-3" d="M286.5,302.5s4-50-16-59-44-8-57,6-22,43-22,53Z"/><path class="cls-10-4" d="M268.5,247.5s-8,8-11,26-2.25,29-2.25,29H286.5s3.84-41.79-12.58-56.9C273.92,245.6,271.5,244.5,268.5,247.5Z"/><path class="cls-10-5" d="M226.5,236.5s-6-10-26-12-56-2-81,13-43,44-43,65h115s3-31,22-53c0,0,8.88-7.45,14.44-9.22C227.94,240.28,228.5,239.5,226.5,236.5Z"/><path class="cls-10-6" d="M218.22,229.55s-18.72.95-31.72,20.95-17,37-17.5,52h22.5c-1.42.12,8.08-36.84,16-45.5,10-15.5,21-16.5,21-16.5S228.93,235.6,218.22,229.55Z"/><path class="cls-10-7" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-10-2" d="M80.61,255.48s2.89,2-6.11,12a94,94,0,0,0-17,26c-3,7-1.75,9-1.75,9H49.5S50.73,270.47,80.61,255.48Z"/><path class="cls-10-3" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-10-7" d="M153.5,251.5c7.6-.12,26-2,27-14s-27-8-27-8-22,1-22,11S147.35,251.6,153.5,251.5Z"/><path class="cls-10-8" d="M138.5,166.5s12,33,4,72c0,0,1,6,12,5s12-5,12-5,6-37-10-75C156.5,163.5,145.5,166.5,138.5,166.5Z"/><path class="cls-10-7" d="M161,176a15.59,15.59,0,0,1-7.53,3.51c-5,1-9,1-11.52,0"/><path class="cls-10-7" d="M163.66,185.38c-1.18,2.51-2.36,7.71-20,5.42"/><path class="cls-10-7" d="M166.32,200.74s-4.63,7.77-21.23,3.27"/><path class="cls-10-7" d="M167.42,213.41s-3.73,9.13-22.33,4.11"/><path class="cls-10-7" d="M167.42,227.17s-7.75,9.36-23.34,1.85"/><circle cx="154.5" cy="247.5" r="1.5"/><circle cx="166.5" cy="244.5" r="1.5"/><circle cx="174.5" cy="237.5" r="1.5"/><circle cx="168.5" cy="232.5" r="1.5"/><circle cx="142.5" cy="245.5" r="1.5"/><circle cx="137.5" cy="240.5" r="1.5"/><path class="cls-10-9" d="M150.5,164.5s10.5,29.5,11,45a328.75,328.75,0,0,1-1,33l6-3c3.29-1.87.5-61.5-10-76Z"/>"""
),
Part(
""".cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""",
"""<g id="body-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M160.5,246.5s-14-3-27,13-22,45-22,45h108s-20-35-25-40S176.5,247.5,160.5,246.5Z"/><path class="cls-11-2" d="M121.5,303.5s5-24,10-33,3.28-8.07,7.64-8.53,15.13-1.22,23.75,2.66,8.18,3.25,8.18,3.25l13.12-12.15s-12.68-9.66-26.18-9.44-23.5,12.22-26.5,15.22-14.17,23.45-19.09,40.22Z"/><path class="cls-11-3" d="M145.5,261.5s28,1,38,17,13,26,13,26h-86s13.77-34.15,18.39-38.57S144.5,261.5,145.5,261.5Z"/><path class="cls-11-2" d="M121.5,303.5s5-24,10-33,3.28-8.07,7.64-8.53c22.36-1.47,31.93,5.9,31.93,5.9l13.12-12.15s-12.68-9.66-26.18-9.44-23.5,12.22-26.5,15.22-14.17,23.45-19.09,40.22Z"/><path class="cls-11-3" d="M160.5,246.5s-14-3-27,13-22,45-22,45h108s-20-35-25-40S176.5,247.5,160.5,246.5Z"/><path class="cls-11-4" d="M149,192s1,62,2,64a11.16,11.16,0,0,0,9.06,3.21c5.44-.71,6.44-2.71,6.94-5.21,0,0-2.5-48.5-2.5-62.5Z"/><path class="cls-11-5" d="M159.5,191.5s3,36,3,40,1,27,1,27l3-1-2-66Z"/><path class="cls-11-3" d="M164.5,203.5a8.76,8.76,0,0,1-6,2c-4,0-8,0-9-1"/><path class="cls-11-3" d="M165.5,214.5a12.68,12.68,0,0,1-6,2c-3,0-7,.25-10-1.37"/><path class="cls-11-3" d="M165.8,225.5a10.11,10.11,0,0,1-6.3,2c-4,0-7.65-.2-9.82-2.1"/><path class="cls-11-3" d="M166,234.5s-.5,3-5.5,3a64.39,64.39,0,0,1-10.3-1"/><path class="cls-11-3" d="M166.5,245.5s-1,3-6,3a21.51,21.51,0,0,1-10-2"/><path class="cls-11-6" d="M195.58,301.91H218S203,275.78,198.73,270.14,186.5,256.5,184.5,255.5l-13,12s8,4.69,10.48,8.84S192.66,293.32,195.58,301.91Z"/><path class="cls-11-4" d="M193.5,272.5a6.85,6.85,0,0,0-2,8c2,5,8,6,8,6s7-6,15-2,9,7,9,15,14,3,14,3l2-2s4-21-19-31C220.5,269.5,209.5,265.5,193.5,272.5Z"/><path class="cls-11-6" d="M196.5,284.5a25.68,25.68,0,0,1,18-5c11,1,16,9,18,17s-4,10-4,10l-5-7s.05-13.88-10-15.44-14,2.44-14,2.44S195.5,286.5,196.5,284.5Z"/><path class="cls-11-3" d="M206,268.82a2.89,2.89,0,0,0-1.5,2.68c0,2,.1,8.87,7,11.94"/><path class="cls-11-3" d="M229.44,274.89s-3.94-1.39-5.94,1.61-3.62,9.25-2.81,12.13"/><path class="cls-11-3" d="M223.56,300.34c-.06.16.94-2.84,5.94-2.84a21.66,21.66,0,0,1,10.09,2.37"/><path class="cls-11-4" d="M127.07,268.36S108.5,263.5,98.5,279.5c0,0-5,9,1,20s7,4,7,4l7-5.25s-6-7.75-2-13.75c0,0,2.75-4.25,8.88-3.12Z"/><path class="cls-11-3" d="M119.5,281.5s2-7,1-10a5.45,5.45,0,0,0-3.56-3.62"/><path class="cls-11-3" d="M111.5,284.5a14.54,14.54,0,0,0-8-5c-5-1-5.44.94-5.44.94"/><path class="cls-11-3" d="M111,293.56a10.89,10.89,0,0,0-7.48.94c-4,2-4,5-4,5"/><path class="cls-11-6" d="M123.53,274.89s-7-2.39-13,.61-9,7-9,12,5,12,7,14,4.83-3.86,4.83-3.86-3.83-5.14-2.83-11.14c0,0,1-6,10-5Z"/><circle cx="145" cy="266" r="1.5"/><circle cx="160.5" cy="268.5" r="1.5"/><circle cx="173.5" cy="274.5" r="1.5"/><circle cx="182.5" cy="285.5" r="1.5"/><circle cx="188.5" cy="296.5" r="1.5"/><circle cx="133.5" cy="266.5" r="1.5"/></g>"""
),
Part(
""".cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""",
"""<g id="body-03"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M180.45,252.21s-17.95-2.71-29.95,2.29-15,8-16,24c0,0,1,27,1,28s49,1,49,1v-9a27.15,27.15,0,0,1-3-13c0-8,7-6.16,7-6.16S177,279,177.24,263.25A16.2,16.2,0,0,1,180.45,252.21Z"/><path class="cls-fill-1" d="M133.5,277.5s-6.92-1.5-8-5.25l-.68-1.62c-.37-6.66,1.07-12,5.72-15,0,0,5.17-4.37,11.55-4.24,0,0,5.37.13,7.37,3.13,0,0-9,3.6-12,9.3s-3.09,13.32-3,14Z"/><path class="cls-fill-1" d="M188.5,247.5s-8,2-10,8-3,19,8,23,19-7,19-17S201.5,245.5,188.5,247.5Z"/></g><path class="cls-12-2" d="M134.5,254.5s3-1,3,0-6,5-7,10,0,8-2,8-4.67.09-2.84-10.45A13,13,0,0,1,134.5,254.5Z"/><path class="cls-12-3" d="M133.5,277.5s-6.92-1.5-8-5.25l-.68-1.62c-.37-6.66,1.07-12,5.72-15,0,0,5.17-4.37,11.55-4.24,0,0,5.37.13,7.37,3.13,0,0-9,3.6-12,9.3s-3.09,13.32-3,14Z"/><path class="cls-12-4" d="M189.5,248.5s9,0,9,10-4,16-9,17-7,.77-7,.77a12.77,12.77,0,0,0,8,3.07c5,.16,15-4.84,15-17.84s-7.26-14.64-14.13-14.32S188.5,248.5,189.5,248.5Z"/><path class="cls-12-3" d="M188.5,247.5s-8,2-10,8-3,19,8,23,19-7,19-17S201.5,245.5,188.5,247.5Z"/><path class="cls-12-5" d="M201.86,273.17s9.64,10.33,9.64,28.33h-13s2.74-14.31-7.13-22.15A11.59,11.59,0,0,0,201.86,273.17Z"/><path class="cls-12-4" d="M197.5,279.5s5,8,6,15,0,9,0,9h8S213,293,206.76,280.74c0,0-3.26-6.24-5.26-7.24a22,22,0,0,1-4.74,4.65Z"/><path class="cls-12-3" d="M180.45,252.21s-17.95-2.71-29.95,2.29-15,8-16,24c0,0,1,27,1,28s49,1,49,1v-9a27.15,27.15,0,0,1-3-13c0-8,7-6.16,7-6.16S177,279,177.24,263.25A16.2,16.2,0,0,1,180.45,252.21Z"/><path class="cls-12-6" d="M169.5,304.5s-7-21,0-42c0,0,3.39-7.79,10.2-10.39l.8.39s-3.64,3.11-3.32,10.06,2.16,14.4,9.74,16.17a5.19,5.19,0,0,0-5,3.77c-1.44,4,.38,11.28,1.47,13.64A13.05,13.05,0,0,1,184.5,301Z"/><path class="cls-12-2" d="M142.5,259.5a2.19,2.19,0,0,1,3,1q1.5,3-3,9c-3,4-3,14-2,19s2,10,2,13h-7.16S134,284,134.75,275.26s2.49-12.37,6.12-15.56Z"/><path class="cls-12-3" d="M150,254.71s-4.5-4.21-10.5-3.21-14,6-15,15,9,12,10,12C134.5,278.5,132.5,259.92,150,254.71Z"/><path class="cls-12-5" d="M130.17,276.81a64.62,64.62,0,0,1-1.51,10.87c-1.2,5.22-3.35,11-7.16,13.82l13.79-.59s-.79-21.41-.79-22.41Z"/><path class="cls-12-3" d="M134.5,290.5s-2.86-2.88-5.93-2.44"/><path class="cls-12-3" d="M124,299s5.55-1.48,9,2"/><path class="cls-12-5" d="M150,192s1,61,2,64c0,0,2,5,10,3,0,0,6-1,6-7,0,0-3.5-40.5-2.5-60.5C165.5,191.5,152.5,191.5,150,192Z"/><path class="cls-12-7" d="M164.5,202.5s0,3-5,3-8.56-1-9.28-1.5"/><path class="cls-12-7" d="M165,214.5s-1,2-5.18,2a39.35,39.35,0,0,1-9.38-1.58"/><path class="cls-12-3" d="M166,224.5s0,2-4.62,3-10.38-1-10.38-1"/><path class="cls-12-3" d="M166,233.5s0,2-3.21,3a16.53,16.53,0,0,1-11.79-1"/><path class="cls-12-3" d="M167,244.5a7,7,0,0,1-5.17,4c-4.13,1-10.48-1.51-10.48-1.51"/><path class="cls-12-3" d="M207,281.6s-4-2.4-12,2.4"/><path class="cls-12-3" d="M211.42,296.16s-2.24-4.33-12.58,1.51"/><path class="cls-12-4" d="M159,191.5h6.5v10s1,20,.89,29.33A182.26,182.26,0,0,0,168,252.75h0c-.5,4.25-5.5,5.75-5.5,5.75v-2c0-2,1-15,1-23s-4-40-4-40Z"/></g>"""
),
Part(
""".cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""",
"""<g id="body-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148.46,242.82s-35,5.68-33,23.68c0,0-1,7,9,20s13,20,13,20l51-2s1-7,9-18,11-13,11-24-13.09-25.36-60-19.68"/><path class="cls-13-2" d="M148.46,242.82s-35,5.68-33,23.68c0,0-1,7,9,20s13,20,13,20l51-2s1-7,9-18,11-13,11-24-13.09-25.36-60-19.68"/><path class="cls-13-3" d="M131.83,247.83s-6.33,4.67-7.33,12.67c0,0-3,7,4,14s13,18,14,25-7.56,2.2-7.56,2.2-5.72-8.89-11.08-16-10.81-14.18-7.58-25.17C116.28,260.49,118.17,254.17,131.83,247.83Z"/><circle cx="155.5" cy="267.5" r="1"/><circle cx="144" cy="268" r="1"/><circle cx="132" cy="270" r="1"/><circle cx="122" cy="274" r="1"/><circle cx="170" cy="267" r="1"/><circle cx="183" cy="270" r="1"/><circle cx="194" cy="275" r="1"/><path class="cls-13-4" d="M193.33,245.17s-1.83,5.33,1.17,8.33,4,4,2,8a21.05,21.05,0,0,0-3.72,4c-1.28,2,.72,4-1.28,9s-15,28-15,28l12.74-.81s-.74-2.19,8.26-15.19,13-17.33,10-29.17C207.5,257.33,204.17,249.83,193.33,245.17Z"/><path class="cls-13-2" d="M116.5,272.83s-2-6.33,6-8.33,19-5,40-5,41,6,40,18a10,10,0,0,1-2.17,5"/><path class="cls-13-5" d="M166.5,205.5h-14a12.13,12.13,0,0,0-5,1l1,38s0,11,10,11,11-9,11-9-3-17-3-27Z"/><path class="cls-13-2" d="M166,212.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-2" d="M166,224.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-2" d="M167,238.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-4" d="M159.17,205.5s-.67,9,1.33,15,4,15,3,21-1,12.74-3,13.87,8-.87,9-8.87a168.89,168.89,0,0,1-3-28.92V205.5Z"/><path class="cls-13-5" d="M197.5,286.5s12,3,14,13,16,4,16,4,4-1-2-15A31.19,31.19,0,0,0,207.19,271a29.16,29.16,0,0,1-5.19,9.56A32.56,32.56,0,0,0,197.5,286.5Z"/><path class="cls-13-2" d="M219.5,279.5s-11.86,4.53-11.93,12.76"/><path class="cls-13-2" d="M228.5,296.5a15.7,15.7,0,0,0-17,4"/><path class="cls-13-4" d="M224,304.64l4.82-4.14s-2.14-16.5-11-23-10.33-6-10.33-6l-3.27,6S222.47,286.77,224,304.64Z"/><path class="cls-13-5" d="M117.31,275.57S103.5,282.5,100.5,301.5s17,0,17,0,1.85-8.67,8.92-12.34C126.42,289.16,118.13,278.64,117.31,275.57Z"/><path class="cls-13-2" d="M109,283s7.48,11.61,11.58,11.55"/><path class="cls-13-2" d="M102,296s6.9,7.76,11.5,6.47"/><path class="cls-13-4" d="M119.5,283.5s-13,7-12,21,10.64-5.12,10.64-5.12,6.36-8.88,8.36-9.88l-5.5-7Z"/></g>"""
),
Part(
""".cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""",
"""<g id="body-05"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M137.41,252.15s-.91-4.65-11.91-3.65-13,12-13,18,4,13,12,14c0,0,3,0,3-1,0,0-3.17-6.44.41-16.72C127.91,262.78,133.33,254.8,137.41,252.15Z"/><path class="cls-fill-1" d="M194.5,244.5s-19,1-19,17,11,21,17,21,20-2,19-20S194.5,244.5,194.5,244.5Z"/><path class="cls-fill-1" d="M181.5,247.5a39.32,39.32,0,0,0-24-4c-14,2-28,14-30,19s-3,9,0,17,3,20,1,22h68s.68-18.41-.66-19.2c0,0-21.34,1.2-20.34-19.8a13.88,13.88,0,0,1,6.67-13.88Z"/></g><path class="cls-14-2" d="M194.5,244.5s-19,1-19,17,11,21,17,21,20-2,19-20S194.5,244.5,194.5,244.5Z"/><path class="cls-14-3" d="M195.5,245.5s8,5,6,18-9.78,17.77-14.39,17.89c0,0,21.41,6.8,24.4-15.54C211.51,265.84,212.5,245.5,195.5,245.5Z"/><path class="cls-14-2" d="M181.5,247.5a39.32,39.32,0,0,0-24-4c-14,2-28,14-30,19s-3,9,0,17,3,20,1,22h68s.68-18.41-.66-19.2c0,0-21.34,1.2-20.34-19.8a13.88,13.88,0,0,1,6.67-13.88Z"/><path class="cls-14-4" d="M195.5,282.5s7,11,6,19h12s2.5-15-7.25-23.5C206.25,278,203.5,282.5,195.5,282.5Z"/><path class="cls-14-2" d="M211.55,285.39s.73,7.21-11.66,6.16"/><path class="cls-14-5" d="M136.5,260.5a3.1,3.1,0,0,0-2,1c-1,1-3,5,0,5s4-1,4-3S137.5,260.5,136.5,260.5Z"/><path class="cls-14-5" d="M133.5,270.5a4.33,4.33,0,0,0-2,4c0,3,1,6,2,12s.67,14-1.67,15,6.67,0,6.67,0,4-16,0-25C138.5,276.5,136.5,269.5,133.5,270.5Z"/><path class="cls-14-3" d="M161.5,253.5s9,2,11,10,2,28,2,28v12l22,1v-19a4.38,4.38,0,0,0-1-3s-22-1-20-21c0,0,0-9,7-13a35,35,0,0,0-14.06-5.07l.06,5.07A10.39,10.39,0,0,1,161.5,253.5Z"/><path class="cls-14-2" d="M141.34,249.41s3.16,11.09,16.16,11.09,19.07-10,19-15"/><path class="cls-14-2" d="M144.5,301.5s2-12,1-18l23-1s1,17,0,19"/><path class="cls-14-2" d="M137.41,252.15s-.91-4.65-11.91-3.65-13,12-13,18,4,13,12,14c0,0,3,0,3-1C123.29,273.33,124.5,260.5,137.41,252.15Z"/><path class="cls-14-6" d="M118.5,252.5s5-3,3,1-5,5-6,11,4,11,4,11,2,2-1,2-7.7-8-5.35-17A16.51,16.51,0,0,1,118.5,252.5Z"/><path class="cls-14-4" d="M119.36,278.79s-3.86,5.71-4.86,10.71,1,12,1,12h10s-1-7,1-12,2.48-4.68,2.48-4.68l-1.48-5.32s-1.27,1.4-4.63.7-3.87-1.4-3.87-1.4"/><path class="cls-14-2" d="M114.92,286.75s3.58,4.75,11.58,2.75"/><circle cx="158.5" cy="257.5" r="1"/><circle cx="150" cy="255" r="1"/><circle cx="146" cy="251" r="1"/><circle cx="168" cy="254" r="1"/><circle cx="172" cy="247" r="1"/><path class="cls-14-3" d="M125,280.39s-4.48,3.61-3.22,21.11H125s-1-11.5,4-16.5l-1.58-5.08Z"/><path class="cls-14-3" d="M202,281.28S209,293,209,302s4.79-1.37,4.79-1.37S215,286,206,278A6.19,6.19,0,0,1,202,281.28Z"/><path class="cls-14-4" d="M147,200s-1,42,2,50c0,0,12,9,20-2,0,0-3.5-26.5-3.5-48.5A77.11,77.11,0,0,0,147,200Z"/><path class="cls-14-2" d="M166,211.5s-1.08,3-8.68,3-10.48-1.37-10.48-1.37"/><path class="cls-14-2" d="M166,223.5s-1.08,3-8.68,3-10.48-1.37-10.48-1.37"/><path class="cls-14-2" d="M147.5,238.5a15.39,15.39,0,0,0,8,2c5,0,11.32-1.58,12.16-4.29"/><path class="cls-14-3" d="M158.67,199.17s.83,11.33,1.83,18.33,2,15,2,20,.33,13.93-1.33,16c0,0,4.33,0,7.33-6,0,0-3-26-3-33v-15Z"/><circle cx="148" cy="286" r="0.5"/><circle cx="165.5" cy="285.5" r="0.5"/></g>"""
),
Part(
""".cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""",
"""<g id="body-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M145.5,301.5s-1-18-1-23c0,0,.37-3.41,15.68-2.2s10.15-.26,10.15-.26,5.17-.54,5.17,2.46,0,19,3,23Z"/><path class="cls-15-2" d="M159.5,286.5s16-1,16-8c0,0,4-3-14-2h-12s-5,0-5,2S143.5,285.5,159.5,286.5Z"/><path class="cls-15-3" d="M145.5,301.5s-1-18-1-23c0,0,.37-3.41,15.68-2.2s10.15-.26,10.15-.26,5.17-.54,5.17,2.46,0,19,3,23Z"/><path class="cls-15-4" d="M167.5,282.5l4.79.67.55,18.33h5.66s-2.64-3-2.82-15l-.18-8s-1-3-4-2.5v3.5A6.93,6.93,0,0,1,167.5,282.5Z"/><circle cx="164" cy="291" r="1.5"/><circle cx="155.5" cy="291.5" r="1.5"/><circle cx="148.5" cy="288.5" r="1.5"/><circle cx="171.5" cy="288.5" r="1.5"/><path class="cls-15-5" d="M146,200s3,68,2,79c0,0,2,6,12,5s11.5-4.5,11.5-4.5-3-75-2-80C169.5,199.5,151.5,199.5,146,200Z"/><path class="cls-15-4" d="M161.5,200.5s0,20,1,27,3,24,4,35a166.5,166.5,0,0,1,.61,20s4.39-2,4.39-3-1.38-41.67-1.69-48.33-.31-31.67-.31-31.67h-8Z"/><path class="cls-15-3" d="M146.43,210.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,222.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,234.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,246.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M148.43,259.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M148.43,271.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-4" d="M144.5,281.5l2,21,33-1s-4-6-4-23c0,0-1,8-15,8S145.5,282.5,144.5,281.5Z"/></g>"""
),
Part(
""".cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""",
"""<g id="body-07"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27,19-6,19-6S219.5,231.5,158.5,234.5Z"/><path class="cls-fill-1" d="M277.5,301.5c-.33-7.3-4.07-15.56-12-25,0,0-11.5-7.5-25.28-2.69-7.72,4.69-13.72,11.69-11.72,28.69Z"/><path class="cls-fill-1" d="M74.5,276.5a29.05,29.05,0,0,0-26,16c-9,17,10,17,10,17l8.79-9.5S64.5,293.5,74.5,276.5Z"/></g><path class="cls-16-2" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27,19-7,19-7S219.5,231.5,158.5,234.5Z"/><path class="cls-16-3" d="M215,245s6.5,1.46-.5,16.46-13,26-10,41,24.32,0,24.32,0-3.6-15.79,7-25.4c12.91-11.65,19.71-3.56,16.64-5.6C252.5,271.5,235.49,253.58,215,245Z"/><path class="cls-16-2" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27c7-9,19-7,19-7S219.5,231.5,158.5,234.5Z"/><path class="cls-16-4" d="M62.5,280.5s5,0,0,9-8,11-9,14-6.5-2.33-6.5-2.33-3.23-4.81,5.63-14.74C52.63,286.43,57.5,280.5,62.5,280.5Z"/><path class="cls-16-5" d="M74.5,276.5a29.05,29.05,0,0,0-26,16c-9,17,10,17,10,17l8.79-9.5S64.5,293.5,74.5,276.5Z"/><path class="cls-16-4" d="M77.5,301.5s0-19,13-30,16-5,15-2-14,24-14,32S77.5,301.5,77.5,301.5Z"/><path class="cls-16-5" d="M277.5,301.5s-5-39-37.28-27.69c-7.72,4.69-13.72,11.69-11.72,28.69Z"/><path class="cls-16-3" d="M261.5,304.5s4-7,3-15-8.36-16.57-8.36-16.57a25.86,25.86,0,0,1,18.77,16.29,27.17,27.17,0,0,1,2.58,15.29Z"/><path class="cls-16-6" d="M103.5,303.5s2-30,41-29,41,29,41,29H174.25c-.75-2-12.12-18.58-30.75-18-15.88-.37-26.93,4.64-30.5,18Z"/><path class="cls-16-7" d="M113.5,301.5s3-16,28-16,32,17,32,17Z"/><path class="cls-16-8" d="M118.5,300.5s9-12,24-11,24,12,24,12Z"/><circle cx="125.5" cy="283.5" r="1"/><circle cx="113" cy="291" r="1"/><circle cx="148" cy="280" r="1"/><circle cx="170" cy="289" r="1"/><path class="cls-16-9" d="M147,200s0,44,1,45a14.41,14.41,0,0,0,14,4c8-2,7-7,7-7a144.32,144.32,0,0,1-2.5-24.5v-19S148.5,198.5,147,200Z"/><path class="cls-16-3" d="M159.5,202.5s1,17,2,23,2,22.83-2,23.91c0,0,9.62-.9,9.31-8.41s-2.43-19.17-2.37-30.34.06-12.17.06-12.17l-7,.11Z"/><path class="cls-16-5" d="M147,233.5s1.08,3,5.4,3,12.83-1.33,15-4.66"/><path class="cls-16-5" d="M147.5,219.32s1,2.68,5,2.68,11.89-1.18,13.94-4.16"/><path class="cls-16-5" d="M147.5,208.07s1,1.93,5,1.93,11.89-.85,13.94-3"/></g>"""
),
Part(
""".cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""",
"""<g id="body-08"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M125.5,286.5s-5,5-4,15l83,1s-2-19-13-22-16-1.95-16-1.95h-.39l.39,3.95s-4.64,7.17-15.82,7.08-13.57-2.76-15.87-5.92c0,0-1.3-4.45-.8-6.81C143,276.85,135.5,277.5,125.5,286.5Z"/><path class="cls-fill-1" d="M143.5,272.5a4.87,4.87,0,0,0-.19,1.41c0,2.53,1.69,7.59,15.19,7.59,18,0,16-9,16-9s-1-5-13-5S145.5,268.5,143.5,272.5Z"/><path class="cls-fill-1" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10s-.12-5-15.56-5-14.44,4-15.44,6a10.07,10.07,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/></g><path class="cls-17-2" d="M166,276l4,3s6-4,4-7-2.41-2.08-2.41-2.08L171,274Z"/><path class="cls-17-3" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10c-2.8,12.94-32.54,10.72-31,1a24,24,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/><path class="cls-17-4" d="M143.5,272.5a4.87,4.87,0,0,0-.19,1.41c0,2.53,1.69,7.59,15.19,7.59,18,0,16-9,16-9s-1-5-13-5S145.5,268.5,143.5,272.5Z"/><path class="cls-17-5" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10s-.12-5-15.56-5-14.44,4-15.44,6a10.07,10.07,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/><path class="cls-17-5" d="M125.5,286.5s-5,5-4,15l83,1s-2-19-13-22-16-1.95-16-1.95h-.39l.39,3.95s-4.64,7.17-15.82,7.08-13.57-2.76-15.87-5.92c0,0-1.3-4.45-.8-6.81C143,276.85,135.5,277.5,125.5,286.5Z"/><path class="cls-17-6" d="M134.5,281.5s5-1,3,2-7,4-9,9a17.55,17.55,0,0,0-1.2,9.07l-5.8-.07S120,288.59,129.74,283A12.58,12.58,0,0,1,134.5,281.5Z"/><path class="cls-17-7" d="M168.21,288.58s15.29-5.08,20.29-1.08,5,12,5,14,11,0,11,0-2.7-17.32-10.85-20.16a50.64,50.64,0,0,0-18.15-2.84h0v4S169.92,288.67,168.21,288.58Z"/><path class="cls-17-7" d="M170.33,279.34v7.9s5.17-3.74,5.17-4.74-.94-9.36-.94-9.36S175.16,277.17,170.33,279.34Z"/><path class="cls-17-8" d="M143,201s8,21,7,38-2,35-2,35,.5,3.5,10.5,3.5,13-5,13-5,0-37-3-53-8-22-8-22S147.5,200.5,143,201Z"/><path class="cls-17-3" d="M153.89,199s6.61,15.53,8.61,28.53,4,24,4,33v15.63s5-1.63,5-3.63,1.23-37.53-5.39-62.77c0,0-3.61-10.23-5.61-12.23Z"/><path class="cls-17-5" d="M150,226.5s14.89,2.35,18.24-8.32"/><path class="cls-17-5" d="M150.5,237.5s1,4,8,3,11-4,12-6"/><path class="cls-17-5" d="M149.63,250.63s.88,3.88,6.88,3.88S170,251,171.24,248.74"/><path class="cls-17-5" d="M147.5,213.5s1,2,6,2,12.85-4.58,12.43-6.29"/><path class="cls-17-5" d="M149,262.5s0,5,8.18,4,12.77-3.62,14.06-6.31"/></g>"""
),
Part(
""".cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""",
"""<g id="body-09"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M95.13,227.69S74.5,232.5,73.5,248.5c-.44,7.05,4.25,12.09,9.63,15.53a52.45,52.45,0,0,0,14.79,6.13s-3.16-18.66-2.79-30.66S95.13,227.69,95.13,227.69Z"/><path class="cls-fill-1" d="M97.5,301.5s2-21,0-35-2-29-2-36,14-26.11,46-28.05,59-1.95,63,18.05,3,30,3,30,2,53,0,55S97.5,301.5,97.5,301.5Z"/><path class="cls-fill-2" d="M204.5,220.5s28,1,29,23S216.6,267,208,268.24C208,268.24,209.5,233.5,204.5,220.5Z"/></g><path class="cls-18-3" d="M95.5,230.5s-7,20,40,23,69-15.69,69.51-30.35-12.33-22.39-48.92-21.52S100.5,212.5,95.5,230.5Z"/><path class="cls-18-4" d="M87.5,232.5s-9,9-9,14,6,16,2,15-7.57-12-5.79-18.48S86.5,229.5,87.5,232.5Z"/><path class="cls-18-3" d="M97.5,301.5s2-21,0-35-2-29-2-36,14-26.11,46-28.05,59-1.95,63,18.05,3,30,3,30,2,53,0,55S97.5,301.5,97.5,301.5Z"/><path class="cls-18-5" d="M102.5,227.5s-4,22,38,21c0,0,56,2,57-26s-69-14-69-14S106.5,214.5,102.5,227.5Z"/><path class="cls-18-3" d="M95.5,230.5s-7,20,40,23,69-15.69,69.51-30.35-12.33-22.39-48.92-21.52S100.5,212.5,95.5,230.5Z"/><path class="cls-18-3" d="M95.13,227.69S74.5,232.5,73.5,248.5c-.44,7.05,4.25,12.09,9.63,15.53a52.45,52.45,0,0,0,14.79,6.13s-3.16-18.66-2.79-30.66S95.13,227.69,95.13,227.69Z"/><path class="cls-18-3" d="M204.5,220.5s28,1,29,23S216.6,267,208,268.24C208,268.24,209.5,233.5,204.5,220.5Z"/><path class="cls-18-4" d="M104.5,263.5a4.45,4.45,0,0,0,2,5c3,2,7,0,6-2S106.5,260.5,104.5,263.5Z"/><path class="cls-18-4" d="M109.5,276.5s-5-1-5,9a161.75,161.75,0,0,0,1,18l11,1s-3-5-3-14S112.5,278.5,109.5,276.5Z"/><path class="cls-18-6" d="M224.9,263.3s-1.4,20.2,16.6,39.2h-22s-7.35-6.33-11.18-20.67l.18-13.33S218.3,268.1,224.9,263.3Z"/><path class="cls-18-3" d="M208.5,281.5a10.58,10.58,0,0,1,11-6c8,1,7.71,3,7.71,3"/><path class="cls-18-3" d="M215.68,298s4.82-4.52,10.82-4.52a18.1,18.1,0,0,1,9.41,2.26"/><path class="cls-18-7" d="M217.85,223.44s9.65,6.06,7.65,17.06-8,16.09-17,18.54v9.46s24.08-.06,25-22.53C233.54,246,236.21,233.39,217.85,223.44Z"/><path class="cls-18-6" d="M76.5,302.5s9-10,8-24l-1-14,14,6s2,24,0,31S76.5,302.5,76.5,302.5Z"/><path class="cls-18-3" d="M97.5,282.5s-3.87-3-12.94-1"/><path class="cls-18-3" d="M97.5,298.5s-2.26-5.48-16.63-2.74"/><path class="cls-18-8" d="M90.5,270.5s7,25,0,32l7-1v-31l-8.76-3.76Z"/><path class="cls-18-8" d="M85.5,273.5s5,22-2,29-4.6-3.27-4.6-3.27,4.73-4.71,5.66-15.22l.94-10.51"/><path class="cls-18-8" d="M225.8,272.89s-12.3-6.39-12.3.61,11,28,14,31-6,0-6,0-10.71-12.76-12.36-19.88-.64-16.12-.64-16.12l16.4-5.2Z"/><path class="cls-18-8" d="M197.51,304.5s4-34,0-53c-2.39-11.36-2.58-11.5-2.55-11.32,0,0,8.57-4.37,10.06-17s3.49,45.35,3.49,45.35v37Z"/><circle cx="104" cy="254" r="1.5"/><circle cx="115.5" cy="258.5" r="1.5"/><circle cx="129.5" cy="261.5" r="1.5"/><circle cx="146.5" cy="262.5" r="1.5"/><circle cx="164.5" cy="260.5" r="1.5"/><circle cx="180.5" cy="255.5" r="1.5"/><circle cx="193.5" cy="248.5" r="1.5"/><circle cx="202.5" cy="241.5" r="1.5"/><path class="cls-18-6" d="M139.5,181.5l1,39s0,7,10,7,11-10,11-10a155.16,155.16,0,0,1-3-16c-1-8,0-20,0-20h-19Z"/><path class="cls-18-3" d="M158,185.5s.33,3.92-18.35,2"/><path class="cls-18-3" d="M158,197.5s0,6-17.5,1"/><path class="cls-18-3" d="M160,210.5s-2.35,7-20,2"/><path class="cls-18-8" d="M151.5,183.5a14.82,14.82,0,0,0,0,10c2,5,5,18,4,24a61.38,61.38,0,0,1-2.35,9.74s9.35-4.74,8.35-9.74-2.37-8.51-3.18-17.76a104.59,104.59,0,0,1,.18-18.24h-6.4Z"/></g>"""
),
Part(
""".cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""",
"""<g id="body-10"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M222.5,301.5s-10-35,11-53,58-15,70,2,2,51,2,51Z"/><path class="cls-fill-1" d="M83.5,254.5s-6,28,67,26,77-30,76-36-12.3-10.12-26-13c0,5,1,22,1,22s-8,13-46,17c-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/><path class="cls-fill-1" d="M110.5,261.5s20,11,44,9,36-7,47-17l-1-24a10.53,10.53,0,0,1-.31,4.41c-.69,1.59-4.63,16.78-42.16,19.68s-47-4.1-50-16.1Z"/><rect class="cls-fill-1" x="83.5" y="250" width="143.5" height="58.5"/><path class="cls-fill-1" d="M87.5,247.5s-19-10-37-5-29,20-32,37c-1,11,1,16,3,20h64s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-fill-1" d="M108,237s-1,20,45,17,48.5-20.5,47.5-24.5c-.91-3.63-13.68-10.56-39-9.56,0,.56.26,3.3.26,3.3,1.74,9.26.74,15.26-9.26,16.26-3.41.33-10-5-10-5s-1-8-1-13C120.38,224.59,108.41,230,108,237Z"/></g><path class="cls-19-2" d="M108,237s-1,20,45,17,48.5-20.5,47.5-24.5c-.91-3.63-13.68-10.56-39-9.56,0,.56.26,3.3.26,3.3,1.74,9.26.74,15.26-9.26,16.26-3.41.33-10-5-10-5s-1-8-1-13C120.38,224.59,108.41,230,108,237Z"/><path class="cls-19-3" d="M110.5,261.5s20,11,44,9,36-7,47-17l-1-24a10.53,10.53,0,0,1-.31,4.41c-.69,1.59-4.63,16.78-42.16,19.68s-47-4.1-50-16.1Z"/><path class="cls-19-4" d="M193.37,260.15c-6.66,3.83-18.28,8.29-37.87,10.35-29,1-45-9-45-9s-1.5-16-2.5-23.13c-11.5,3.38-22.5,9.13-24.5,16.13,0,0-6,28,67,26,29.58-.81,47.84-5.89,59-12"/><path class="cls-19-5" d="M83.5,254.5s-6,28,67,26c29.58-.81,47.84-5.89,59-12-5.5-2.5-10.5-5.5-16.13-8.35-6.66,3.83-18.28,8.29-37.87,10.35-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/><path class="cls-19-6" d="M141.5,190.5v29a142.25,142.25,0,0,0,1,15,9.78,9.78,0,0,0,10,5c7-1,11-4,10-11-.36-2.54-.73-5-1-7.56a138.9,138.9,0,0,1-1-17.44v-14A69.37,69.37,0,0,0,141.5,190.5Z"/><path class="cls-19-7" d="M152.5,190.5s0,7,2,12a55.9,55.9,0,0,1,3.13,17.34c-.12,3.66.88,15.66-3.12,17.66s-2,2-2,2,7-1.57,8.48-3.79,1.94-3.24,1.23-9.23a177.33,177.33,0,0,1-1.71-24.86V189.5a37.46,37.46,0,0,0-6.17-.25l-1.83.25Z"/><path class="cls-19-8" d="M141.5,190.5v29a142.25,142.25,0,0,0,1,15,9.78,9.78,0,0,0,10,5c7-1,11-4,10-11-.36-2.54-.73-5-1-7.56a138.9,138.9,0,0,1-1-17.44v-14A69.37,69.37,0,0,0,141.5,190.5Z"/><path class="cls-19-3" d="M161.25,222.25s-2.67,3.25-9.51,3.25-10.13-2-10.13-2"/><path class="cls-19-3" d="M160,208.5s-3,3-9.06,3S142,210,142,210"/><path class="cls-19-3" d="M160,197.5s-2,2-8.06,2A86.48,86.48,0,0,1,142,199"/><path class="cls-19-9" d="M188,246.51,189.52,262s6.63-4,9.06-6l2.42-2s.72.24.36-3.88-.79-17.93-.79-17.93S200,240,188,246.51Z"/><path class="cls-19-10" d="M84,273.63S97,277,97,285s-1,9,0,14,0,4,0,4H84s3.32-3,2.41-11.26S85,279.5,85,279.5Z"/><path class="cls-19-8" d="M87.5,247.5s-19-10-37-5-29,20-32,37c-1,11,1,16,3,20h64s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-19-9" d="M87.5,247.5a63.65,63.65,0,0,0-25.06-6.39c7.06,2.39,3.06,8.39-8.94,13.39-11,6-23,10-27.5,32.5-.67,7.37.84,13.18,1.5,14.5l58-2s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-19-11" d="M33.5,299.5s-7-19-3-35a28.63,28.63,0,0,1,17-21.06"/><path class="cls-19-12" d="M62.44,241.12a3.46,3.46,0,0,1,3.06,3.38c0,3,0,4-10,9s-21.45,10-26.22,21-2.62,25-2.62,25H21.5l-3-20S27.38,241.73,62.44,241.12Z"/><path class="cls-19-9" d="M84.26,275.62A31.64,31.64,0,0,1,80.5,290.5a19.71,19.71,0,0,1-11.14,8.83l16.14.17s1.42-2.62.71-10.81A90.57,90.57,0,0,0,84.26,275.62Z"/><path class="cls-19-13" d="M222.5,301.5s-10-35,11-53,58-15,70,2,2,51,2,51Z"/><path class="cls-19-14" d="M292.5,241.5s-12-1-13,15c-1,14,4,41,7,45"/><path class="cls-19-9" d="M226.5,244.5l.74,11a39.65,39.65,0,0,0-7.12,24.19c.38,14.81,2.38,21.81,2.38,21.81h-12s1-9,.5-14.5-1.49-18.55-1.49-18.55S227.5,259.5,226.5,244.5Z"/><path class="cls-19-8" d="M209.51,268.45s18-9,17-24l.74,11a39.65,39.65,0,0,0-7.12,24.19c.38,14.81,2.38,21.81,2.38,21.81h-12"/><path class="cls-19-9" d="M293.9,242.19c-1.9-1.19,6.6,6.31,7.6,15.31s7.11,6.39,7.11,6.39-.11-8.39-5.11-13.39S295.81,243.38,293.9,242.19Z"/><circle cx="154" cy="262" r="1.5"/><circle cx="170.5" cy="259.5" r="1.5"/><circle cx="183.5" cy="255.5" r="1.5"/><circle cx="194.5" cy="250.5" r="1.5"/><circle cx="138.5" cy="261.5" r="1.5"/><circle cx="125.5" cy="259.5" r="1.5"/><circle cx="115.5" cy="255.5" r="1.5"/><path class="cls-19-8" d="M83.5,254.5s-6,28,67,26,77-30,76-36-12.3-10.12-26-13c0,5,1,22,1,22s-8,13-46,17c-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/></g>"""
)
)
private val eyes: List<Part> = listOf(
Part(
""".cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""",
"""<g id="eyes-01"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M144.5,141.5s5,9,9,7,13-9,13-9v-9s-2-1-7,0-14,5-14,5S142.5,137.5,144.5,141.5Z"/><path class="cls-fill-1" d="M118,141l-5,10s-7.5-2.5-10.5-4.5-4-3-4-6l1-3a13.6,13.6,0,0,1,7,0c4,1,11,2,11,2S118.5,139.5,118,141Z"/></g><path class="cls-20-2" d="M144.5,141.5s5,9,9,7,13-9,13-9v-9s-2-1-7,0-14,5-14,5S142.5,137.5,144.5,141.5Z"/><path class="cls-20-3" d="M144.5,139.5s2,2,6,1,10-6,12-7a18.66,18.66,0,0,0,4-3s-3.22-1-9.11.52a67.92,67.92,0,0,0-11.89,4.48S142.5,137.5,144.5,139.5Z"/><path class="cls-20-2" d="M118,141l-5,10s-7.5-2.5-10.5-4.5-4-3-4-6l1-3a13.6,13.6,0,0,1,7,0,37.46,37.46,0,0,0,8,1S118.5,139.5,118,141Z"/><path class="cls-20-3" d="M100.5,139.5l6,3c2,1,9.7,2.6,11.35-1.2s-11.6-4.69-16-4.24c0,0-3-.17-2.67.94A2.3,2.3,0,0,0,100.5,139.5Z"/></g>"""
),
Part(
""".cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""",
"""<g id="eyes-02"><g id="fill_color" data-name="fill color"><path id="fillcolor" class="cls-fill-1" d="M110.5,122.5c-4,0-17,2-17,17s10,17,17,17,15-5,15-18S114.5,122.5,110.5,122.5Z"/><path id="fillcolor-2" data-name="fillcolor" class="cls-fill-1" d="M153.5,121.5c-4,0-17.5,2.5-17.5,17.5s10.5,16.5,17.5,16.5,16-5,16-18C167,122,157.5,121.5,153.5,121.5Z"/></g><path class="cls-21-2" d="M110.5,126.5s-13,0-13,12,10,13,13,13,12-2,12-14.52C122.5,137,121.5,127.5,110.5,126.5Z"/><path class="cls-21-3" d="M154.5,121.5s-20,0-19,18,19,16,19,16,16-2,15-17S158.5,121.5,154.5,121.5Z"/><path class="cls-21-3" d="M110.5,122.5c-4,0-17,2-17,17s10,17,17,17,15-5,15-18S114.5,122.5,110.5,122.5Z"/><path class="cls-21-4" d="M140.1,137.73s-2,13.2,12.18,13.2,14.22-10.15,14.22-13.2-3-12.18-12.18-12.18S140.1,132.65,140.1,137.73Z"/><path class="cls-21-5" d="M109,127s-9,1-9,9,2.5,12.5,10.5,12.5,12-5,12-10S117.5,126.5,109,127Z"/><path class="cls-21-5" d="M154,126s-10.5.52-11,9.4S147,149,154,149s12-6.26,12-11.49S161.5,125.51,154,126Z"/><path class="cls-21-6" d="M110.26,134.06a.92.92,0,0,0-.24.05c-.36.13-1,.61-1,2.31,0,2.36,0,3.54,1.26,3.54s2.51,0,2.51-3.54C112.77,134.06,111.12,133.86,110.26,134.06Z"/><path class="cls-21-6" d="M154.26,134.06a.92.92,0,0,0-.24.05c-.36.13-1,.61-1,2.31,0,2.36,0,3.54,1.26,3.54s2.51,0,2.51-3.54C156.77,134.06,155.12,133.86,154.26,134.06Z"/></g>"""
),
Part(
""".cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""",
"""<g id="eyes-03"><path class="cls-fill-1" d="M135.5,123.5s-34,1-35,17,23,12,23,12,23-2,28-3,18-5,18-14-8-13-17-13S139.5,123.5,135.5,123.5Z"/><path class="cls-22-2" d="M117.31,152.44c-4.51,0-10.59-.74-13.91-4.27a9.8,9.8,0,0,1-2.41-7.64c1-15.37,34.18-16.52,34.52-16.53h0c1.61,0,3.25-.16,5.15-.35A107.89,107.89,0,0,1,152.5,123c8,0,16.5,3.28,16.5,12.5,0,9.71-15.8,13.15-17.6,13.51-4.92,1-27.72,3-27.95,3a42,42,0,0,1-6.14.44Z"/><path d="M152.5,123.5c7.73,0,16,3.15,16,12,0,7.56-10.81,11.74-17.2,13-4.89,1-27.66,3-27.89,3h-.08a41.19,41.19,0,0,1-6,.43c-4.41,0-10.35-.71-13.54-4.12a9.32,9.32,0,0,1-2.27-7.27c.93-14.91,33.7-16.05,34-16.06,1.65,0,3.3-.17,5.22-.36a107.45,107.45,0,0,1,11.78-.64m0-1c-9,0-13,1-17,1,0,0-34,1-35,17-.67,10.67,9.78,12.44,16.81,12.44a41.73,41.73,0,0,0,6.19-.44s23-2,28-3,18-5,18-14-8-13-17-13Z"/><path class="cls-22-3" d="M106.5,132.5s-2,2-1,6,2,9,16,8a306.78,306.78,0,0,0,31-4c6-1,16-1.5,17-6.25s-2.18-11.89-12.09-13.32-12.93-.11-16.92.23-8.25.45-11.62.89-16.37,2.44-22.37,8.44"/><path class="cls-22-4" d="M134,124s-4,2-4,6,3.5,11.5,10.5,10.5,9-5,9-11-6.58-6.54-6.58-6.54-3.42.54-4.42.54S134,124,134,124Z"/><ellipse cx="140" cy="130.5" rx="2" ry="2.5"/><path class="cls-22-5" d="M107.5,131.5s-3,2-2,7c1,4,2,9,16,8a306.78,306.78,0,0,0,31-4c6-1,16-1.5,17-6.25s-2.18-11.89-12.09-13.32-12.93-.11-16.92.23-8.25.45-11.62.89-15.37,1.44-21.37,7.44"/></g>"""
),
Part(
""".cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""",
"""<g id="eyes-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M127.5,127.5c11.08-.53,43-2,43,11,1,15-16.69,15-23.34,15.52s-37.66,1.48-47.66.48-15-4-15-12,8-11,17-13S121,127.81,127.5,127.5Z"/><path class="cls-23-2" d="M94.39,132.09s2.78-4.25,37.95-4.92,37.17,6.33,38.17,11.33-3,10.76-6,11.88c0,0,5.52-6.38-2.24-11.63s-22.72-6.58-29.74-6.91-24.86-.88-27.94-.61S96.28,131.67,94.39,132.09Z"/><path class="cls-23-3" d="M127.5,127.5c11.08-.53,43-2,43,11,1,15-16.69,15-23.34,15.52s-37.66,1.48-47.66.48-15-4-15-12,8-11,17-13S121,127.81,127.5,127.5Z"/><path class="cls-23-4" d="M121.5,131.5c-19,0-36-3-37,11s19,12,40,12,42,0,42-9C166.5,134.5,140.5,131.5,121.5,131.5Z"/><path class="cls-23-5" d="M121.5,140.5s29,0,32,1,2,4-1,4-22-1-31-1-22,1-26,0-1-2,11-3S121.5,140.5,121.5,140.5Z"/></g>"""
),
Part(
""".cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""",
"""<g id="eyes-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M131.5,119.5c-5,0-19,2-19,16,0,15,10,18,18,18s17-7,16-19S136.5,119.5,131.5,119.5Z"/><path class="cls-24-2" d="M131.5,119.5c-5,0-19,2-19,16,0,15,10,18,18,18s17-7,16-19S136.5,119.5,131.5,119.5Z"/><path class="cls-24-2" d="M132,124l-.5,0c-2.51,0-13.61.38-14.5,11-1,12,8.5,13.5,12.5,13.5s13-3,13-13C142.5,124.5,132,124,132,124Z"/><path class="cls-24-3" d="M130.5,124.5s-10,1-10,10,5,11,11,11a11.1,11.1,0,0,0,11-11C142.5,128.5,136.5,124.5,130.5,124.5Z"/><ellipse class="cls-24-4" cx="131.5" cy="134.5" rx="2" ry="3"/><path class="cls-24-5" d="M147.5,133.5h6s1,0,0,1a7.69,7.69,0,0,1-3,2h-4v-2Z"/><path class="cls-24-5" d="M112.49,135.15a29.28,29.28,0,0,1-3.29.35c-1.1,0-1.7,1-2.2,2a.79.79,0,0,0,.2.57,1.56,1.56,0,0,0,.94.36,18.66,18.66,0,0,0,2.15.07c2.2,0,2.32-.35,2.32-.35Z"/></g>"""
),
Part(
""".cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""",
"""<g id="eyes-06"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M112.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C123,130.5,112.71,130,112.71,130Z"/><path class="cls-fill-1" d="M156.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C167,130.5,156.71,130,156.71,130Z"/></g><path class="cls-25-2" d="M112.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C123,130.5,112.71,130,112.71,130Z"/><path class="cls-25-3" d="M111.23,130.5s-9.8,1-9.8,10,4.9,11,10.78,11a11,11,0,0,0,10.78-11C123,134.5,117.12,130.5,111.23,130.5Z"/><ellipse class="cls-25-4" cx="112.22" cy="140.5" rx="1.96" ry="3"/><path class="cls-25-2" d="M156.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C167,130.5,156.71,130,156.71,130Z"/><path class="cls-25-3" d="M155.23,130.5s-9.8,1-9.8,10,4.9,11,10.78,11a11,11,0,0,0,10.78-11C167,134.5,161.12,130.5,155.23,130.5Z"/><ellipse class="cls-25-4" cx="156.22" cy="140.5" rx="1.96" ry="3"/></g>"""
),
Part(
""".cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""",
"""<g id="eyes-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/><path class="cls-26-2" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/><path class="cls-26-3" d="M107.16,130.47S105,133,105,138s2,9,8,10,20.5-1.5,20.5-1.5,11-2,18-2,15.74-2.78,15.87-5.89-.17-7.11-2.52-10.11-7.12-3.56-10.24-3.78-12.52-.54-14.82-.38-10.17.47-12.23.81-7.31,1.1-7.31,1.1l-7.61,1.87S107.83,129.44,107.16,130.47Z"/><path class="cls-26-4" d="M109,135s-1,11,10,10,10-10,10-10,0-6-3-8-2.85-1.25-2.85-1.25-11.28,2.64-12.21,2.94S109,133,109,135Z"/><path class="cls-26-5" d="M142,125s-3,2-3,7,3.5,10.5,9.5,10.5,12-4,12-10-3.66-7.5-3.66-7.5l-4.78-.47-4.22-.16h-3.44l-1.91.13Z"/><path d="M119.42,133s-1.17,0-1.17,2.5,1.17,2.5,1.17,2.5a2.51,2.51,0,0,0,0-5Z"/><path d="M150.08,130a1.8,1.8,0,0,0-1.82,1.94c-.07,2,.78,3,1.65,3.06s1.78-.94,1.85-2.94a1.8,1.8,0,0,0-1.68-2.06"/><path class="cls-26-6" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/></g>"""
),
Part(
""".cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""",
"""<g id="eyes-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M110,135s-1.5,6.5.5,6.5h45v-6s0-2-3-2h-38C111.5,133.5,110.5,133.5,110,135Z"/><path class="cls-27-2" d="M110,135s-1.5,6.5.5,6.5h45v-6s0-2-3-2h-38C111.5,133.5,110.5,133.5,110,135Z"/><path class="cls-27-3" d="M110.83,133.88a25.17,25.17,0,0,0,2.67,4.62c1,1,5,0,9,0h31s2-.12,2,.94V135.5s0-2-3-2H113.31Z"/><line class="cls-27-4" x1="113.5" y1="138.5" x2="109.9" y2="141.21"/></g>"""
),
Part(
""".cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""",
"""<g id="eyes-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M127.5,124.5s-43-1-45,16c-1.5,15.5,25,13,25,13h35s27,0,27-13S158.5,123.5,127.5,124.5Z"/><path class="cls-28-2" d="M127.5,124.5s-43-1-45,16c-1,15,21,13,25,13h35s27,0,27-13S158.5,123.5,127.5,124.5Z"/><path class="cls-28-3" d="M126,132c-15.5.5-28.5-1.5-30.5,6.5-2,9,16,7,22,7s27-1,30-1,10-1,10-3C157.5,133.5,142.5,131.5,126,132Z"/><path class="cls-28-4" d="M90.5,140.5c0,1-5,9,32,8s35-4.33,35-7.67-8.64-9.23-21.32-8.78S95.5,128.5,90.5,140.5Z"/><ellipse class="cls-28-5" cx="112" cy="139" rx="2.5" ry="3.5"/><ellipse class="cls-28-5" cx="138" cy="139" rx="2.5" ry="3.5"/><path class="cls-28-6" d="M93.5,129.5s27-3,41-2c11,0,24,3,29,10s1.39,10.59-.3,11.8,6.3-2.8,6.3-8.8-1.61-11.29-14.3-14.65-30.2-1.18-33.95-1.26S98.5,126.5,93.5,129.5Z"/></g>"""
),
Part(
""".cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""",
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M129.5,123.5s-30,3-31,15,8,13,15,14,20-2,26-3,25,1,27-13-17-14-17-14Z"/><path class="cls-29-2" d="M116.92,125.8s-15.42,2.7-16.42,12.7,15.5,13,24.25,10.5A97.37,97.37,0,0,1,150,145.44c6.5.06,16.78-3.69,16.64-11.31S157.5,122.5,149.5,122.5s-21.12,1.13-21.12,1.13Z"/><path class="cls-29-2" d="M130.5,123.5l-8,25.5c1.6-.06,5.38-.65,9-1.2l7-24.8A47.26,47.26,0,0,1,130.5,123.5Z"/><polygon class="cls-29-2" points="120.77 124.91 113 150 118 150 125.41 124.06 120.77 124.91"/><path class="cls-29-3" d="M129.5,123.5s-30,3-31,15,8,13,15,14,20-2,26-3,25,1,27-13-17-14-17-14Z"/><path class="cls-29-4" d="M106.61,129.46s-6.11,3-6.11,9,5,13,20,11,23-4,28-4,17.29-2.5,18.15-10.25c0,0,.3,10.92-14.42,13.09s-16.25,1.2-21,2.68-28.06,4.23-32.4-7.15C98.84,143.87,95.71,134.43,106.61,129.46Z"/>"""
)
)
private val faces: List<Part> = listOf(
Part(
""".cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""",
"""<g id="face-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M144.5,87.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s13.5-1.5,19-1,14,2,14,2,6-13,19-19,18-8,18-8-4-35-4-52S201.5,88.5,144.5,87.5Z"/><path class="cls-30-2" d="M115.14,97.69s6.36,5.81-2.64,14.81-16,23-13,44,10,40,10,50-.67,18.19-.67,18.19l-2.33-2.19s-15-45-15-76.5S115.14,97.69,115.14,97.69Z"/><path class="cls-30-3" d="M144.5,87.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s9-1,15-1,19,1,19,1a38.06,38.06,0,0,1,18-18c13-6,18-8,18-8s-4-35-4-52S201.5,88.5,144.5,87.5Z"/><path class="cls-30-4" d="M144.5,86.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s9-1,15-1,19,1,19,1a38.06,38.06,0,0,1,18-18c13-6,18-8,18-8s-4-35-4-52S201.5,87.5,144.5,86.5Z"/><path class="cls-30-5" d="M158.5,92.5s20,15,18,32-8,28-12,29a19.27,19.27,0,0,1,8,16c0,11,1,50,1,50l.34,6.83,19.66-8.83s-3.77-39-3.38-63.49,1.38-55.13-31.62-64.82C158.5,89.19,155.5,90.5,158.5,92.5Z"/><path class="cls-30-3" d="M124.5,211.5l37-1,4.76,21.18s-8.76,8.82-8.76,11.82c-2,1-10-1-18-1a147.84,147.84,0,0,0-16,1S118.5,224.5,124.5,211.5Z"/><path class="cls-30-6" d="M159.5,212.5a19.89,19.89,0,0,0-4,14c1,8,2,17,2,17l9-12-5-21Z"/></g>"""
),
Part(
""".cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""",
"""<g id="face-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M147.5,88.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S172,87,147.5,88.5Z"/><path class="cls-31-2" d="M154.5,90.5s18,12,25,47,2,48-7,55-24.12,17.37-42.56,17.68c0,0,56.6,1.84,70.58-33.42.73-1.85,1.84-5.7,1.84-5.7S185.5,158.5,183.17,137c-1.67-14.45,2.33-24.45,6.49-24.63a7,7,0,0,0-.63-1.08C179.68,98,160,87,154.5,90.5Z"/><path class="cls-31-3" d="M147.5,87.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S170.5,85.5,147.5,87.5Z"/><path class="cls-31-3" d="M147.5,88.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S171.5,86.5,147.5,88.5Z"/><path class="cls-31-4" d="M92.22,125.67s2.28,14.83.28,24.83-7.67,21-7.67,21S80.94,147.85,92.22,125.67Z"/><path class="cls-31-4" d="M187.5,113.5s-7,10-4,26,12,27.67,18.5,29.83c0,0,5.83-35.17-12.83-57Z"/><path class="cls-31-5" d="M112.5,112.5s-5-1-10,6-6,11-5,13,7,1,11-7S114.5,114.5,112.5,112.5Z"/><path class="cls-31-5" d="M117.5,102.5s-5,4-2,7a6.31,6.31,0,0,0,9,0c2-2,2-6-1-7A10.56,10.56,0,0,0,117.5,102.5Z"/><circle cx="188" cy="143" r="1.5"/><circle cx="193.5" cy="154.5" r="1.5"/><circle cx="199.5" cy="161.5" r="1.5"/><circle cx="88.5" cy="154.5" r="1.5"/><circle cx="89.5" cy="143.5" r="1.5"/><circle cx="90.5" cy="133.5" r="1.5"/><circle cx="186.5" cy="130.5" r="1.5"/><circle cx="190.5" cy="119.5" r="1.5"/></g>"""
),
Part(
""".cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""",
"""<g id="face-03"><path class="cls-fill-1" d="M147,88c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,89.24,147,88Z"/><path class="cls-32-2" d="M147,88c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,89.24,147,88Z"/><path class="cls-32-2" d="M147,87c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,88.24,147,87Z"/><path class="cls-32-3" d="M135.5,96.5s36-4,45,43c0,0,3,24,3,35s-1,25,1,36l2,11,7-2s-2.53-55.76-3.27-70.88S188.5,90.81,150,88.16c0,0-19.78-1-33.64,8.7,0,0-4.86,4.65.14,2.65S132.5,96.5,135.5,96.5Z"/><path class="cls-32-4" d="M122.5,104.5s-4,1-4,4,2,7,5,6,5-5,4-7S125.5,103.5,122.5,104.5Z"/><path class="cls-32-4" d="M115.5,111.5s-9-6-17,12-1,37-1,37,2,6,8,6,4-9,4-9-4-13-1-24,7-13,7-13S120.5,115.5,115.5,111.5Z"/><circle cx="111" cy="223" r="1.5"/><circle cx="125.5" cy="227.5" r="1.5"/><circle cx="141.5" cy="226.5" r="1.5"/><circle cx="159.5" cy="223.5" r="1.5"/><circle cx="175.5" cy="219.5" r="1.5"/><circle cx="188.5" cy="214.5" r="1.5"/></g>"""
),
Part(
""".cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""",
"""<g id="face-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148,87s-56.5.5-56.5,63.93,33,84.57,48,84.57,45-8.05,51-41.28c0,0,1-16.11,0-25.17s-1-29.2-1-32.22S186.5,88.51,148,87Z"/><path class="cls-33-2" d="M151.5,92.5s17,12,22,42,7,44,3,63S167,226,161.75,229.25s21.67-4.94,26.71-26.84,1.77-35.94,1.77-35.94l-1-32.24s-5-35.64-26.88-43.18c0,0-11.87-4.54-17.37-3S148.5,89.5,151.5,92.5Z"/><path class="cls-33-3" d="M113.5,110.5s-4,0-4,4,4,7,7,5,5-5,4-7S118.5,108.5,113.5,110.5Z"/><path class="cls-33-3" d="M108.5,123.5s-4-1-7,5-4,15-3,24a33.42,33.42,0,0,0,8,18c2,2,6,3,5-4s-5-14-4-25S114.5,124.5,108.5,123.5Z"/><path class="cls-33-4" d="M148,87s-56.5.5-56.5,63.5,33,84,48,84,45-8,51-41c0,0,1-16,0-25s-1-29-1-32S186.5,88.5,148,87Z"/><path class="cls-33-4" d="M148,86s-56.5.5-56.5,63.5,33,84,48,84,45-8,51-41c0,0,1-16,0-25s-1-29-1-32S186.5,87.5,148,86Z"/></g>"""
),
Part(
""".cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""",
"""<g id="face-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M91.5,107.5s4,64,4,77,1,38,1,38,4,11,22,10c36.5.5,60-11,66-16,0,0-3-106-3-116s4-20-36-22c0,0-35.59,1.45-44.8,8.73,0,0-10.2,2.27-9.2,7.27S91.5,107.5,91.5,107.5Z"/><path class="cls-34-2" d="M95.8,102s2.7,53.53,3.7,68.53,5,42,5,45v14.71s-7-3.71-8-7.71-3.65-88.21-3.83-95.61S91.5,107.5,91.5,107.5v-13S91.1,100.43,95.8,102Z"/><path class="cls-34-3" d="M145.5,78.5s-53,5-54,16,23,14,37,13,52-7,53-16S156.5,77.5,145.5,78.5Z"/><path class="cls-34-4" d="M91.5,107.5s4,64,4,77,1,38,1,38,3,9,22,10c32,2,60-11,66-16,0,0-3-106-3-116s4-20-36-22c-16,.67-31.14,3.13-44.8,8.73-4.7,1.42-7.78,3.83-9.2,7.27Z"/><path class="cls-34-5" d="M168.5,103.5s1,49,1,57,1,35,1,41-1,22.92-1,22.92l15-7.92s-2.94-96.67-3-100.83,0-25.17,0-25.17c.34,3.51-3.36,6.57-11,9.17l-2,.72Z"/><circle cx="148" cy="114" r="1.5"/><circle cx="130.5" cy="116.5" r="1.5"/><circle cx="114.5" cy="117.5" r="1.5"/><circle cx="116.5" cy="225.5" r="1.5"/><circle cx="101.5" cy="220.5" r="1.5"/><circle cx="132.5" cy="225.5" r="1.5"/><circle cx="150.5" cy="221.5" r="1.5"/><circle cx="166.5" cy="217.5" r="1.5"/><circle cx="179.5" cy="212.5" r="1.5"/><circle cx="99.5" cy="112.5" r="1.5"/><circle cx="164.5" cy="109.5" r="1.5"/><circle cx="177.5" cy="104.5" r="1.5"/></g>"""
),
Part(
""".cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""",
"""<g id="face-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M156,83s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,81.5,156,83Z"/><path class="cls-35-2" d="M156,83s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,81.5,156,83Z"/><path class="cls-35-2" d="M156,82s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,80.5,156,82Z"/><path class="cls-35-3" d="M181.5,93.5s18,16,16,44-6,41-28,54c0,0-16,3-19,28s0,49.27,0,49.27l12-4.27s-6.87-36.09.07-46.54,7.21-11.15,17.07-15.3,30.94-21.59,29.4-50.87-.09-56-35.32-67.16l-3.94-.77S168.5,84.5,181.5,93.5Z"/><path class="cls-35-4" d="M110.5,117.5s-7,1-10,11-11,25-8,35,7,10,9,5,1-17,5-28S116.5,119.5,110.5,117.5Z"/><path class="cls-35-4" d="M122.5,99.5s-9,6-9,9,0,4,5,4,13-4,13-9S128.5,96.5,122.5,99.5Z"/></g>"""
),
Part(
""".cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""",
"""<g id="face-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M71.5,130.5a51.83,51.83,0,0,0,6,29c8,15,39,71,66,70s76-50,76-93-38-54-77-53S73.5,102.5,71.5,130.5Z"/><path class="cls-36-2" d="M116.5,88s-24.18,5.5-35.59,20.5-9.79,26-9.1,34.48,8.2,22.55,14.44,33,18.64,28.81,22.95,32.65,11.3,11.84,11.3,11.84.3-6.17-5.85-18.58S83.5,164,81.5,143.74,87.5,101.49,116.5,88Z"/><path class="cls-36-3" d="M71.5,130.5a51.83,51.83,0,0,0,6,29c8,15,39,71,66,70s76-50,76-93-38-54-77-53S73.5,102.5,71.5,130.5Z"/><path class="cls-36-4" d="M75.44,115.88S108.5,100.5,140.5,101.5s65,7,67,30-3,34-9,50a89.16,89.16,0,0,1-16.32,27"/><path d="M80,118.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S80,118.5,80,118.5Z"/><path d="M73,133.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S73,133.5,73,133.5Z"/><path d="M98,112.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S98,112.5,98,112.5Z"/><path d="M118,109.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S118,109.5,118,109.5Z"/><path d="M143,107.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S143,107.5,143,107.5Z"/><path d="M164,107.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S164,107.5,164,107.5Z"/><path d="M184,112.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S184,112.5,184,112.5Z"/><path d="M201,125.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S201,125.5,201,125.5Z"/><path d="M202,149.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S202,149.5,202,149.5Z"/><path d="M195,173.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S195,173.5,195,173.5Z"/><path d="M185,195.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S185,195.5,185,195.5Z"/><path d="M169,210.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S169,210.5,169,210.5Z"/><path d="M141,220.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S141,220.5,141,220.5Z"/><path d="M115,209.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S115,209.5,115,209.5Z"/><path class="cls-36-5" d="M199.5,115.5l-9-5.07S188,110,191,120s6.5,25.5,4.5,40.5-7,27-19,40-17.37,20.78-17.19,23.89S184.5,208.5,188.5,202.5s11.93-23.83,16-39S211.5,124.5,199.5,115.5Z"/><path class="cls-36-6" d="M201,100l-10,10s-23.39-8.18-41.7-8.09-33.49-1.25-53.4,6.42L76,116s-.08-2.83,6.71-9.66,18-18.34,48.89-22.09,49.94,2.2,58.92,6.73,12,7.09,12,7.09Z"/></g>"""
),
Part(
""".cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""",
"""<g id="face-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M212.5,90.5s-19.5-20.5-55-12c-31,8-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,96.5,212.5,90.5Z"/><path class="cls-37-2" d="M130.5,88.5s-9,6-12,9-16,15-18,28,2,38,4,46,5,21,4,26-2.93,6.66-2.93,6.66S99.5,183.5,97.5,173.5s-6-27-1.52-51S130.5,85.5,130.5,88.5Z"/><path class="cls-37-3" d="M212.5,90.5s-18.25-20.21-55-12c-31.25,7-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,96.5,212.5,90.5Z"/><path class="cls-37-3" d="M212.5,89.5s-18-19-55-12c-31.23,7.06-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,95.5,212.5,89.5Z"/><path class="cls-37-4" d="M209.5,92.5s-15,0-21,6-16,16-13,41,12,65,12,65l3,13,12-6s-22.77-76.47-7.39-90.23c0,0,2.39-4.77,24.39-3.77,0,0-.4-21-5.7-25Z"/><circle cx="211" cy="103" r="1.5"/><circle cx="195.5" cy="207.5" r="1.5"/><circle cx="182.5" cy="213.5" r="1.5"/><circle cx="165.5" cy="217.5" r="1.5"/><circle cx="146.5" cy="220.5" r="1.5"/><circle cx="129.5" cy="220.5" r="1.5"/><circle cx="114.5" cy="216.5" r="1.5"/></g>"""
),
Part(
""".cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""",
"""<g id="face-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M143,89s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,85.5,143,89Z"/><path class="cls-38-2" d="M143,88s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,84.5,143,88Z"/><path class="cls-38-3" d="M99,121.07S122,96,139.5,98.5a137.38,137.38,0,0,1,21.88,7.59s4.12-7.59,6.12-8.59-17-9-23-9C133,89,109.5,104.64,99,121.07Z"/><path class="cls-38-2" d="M143,89s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,85.5,143,89Z"/><path class="cls-38-4" d="M161.38,106.09a20.31,20.31,0,0,0-1.88,4.41c-1,6,2,11,9,19s20,27,21,45,1,35-13,45-24.21,14.08-24.21,14.08,34.71.42,47.8-19.51c11.41-15.58,10.59-36,0-59.77-11.09-27.31-33.81-44.6-33.81-44.6Z"/><path class="cls-38-5" d="M167.41,97.44c-2.41-.44-5.91,9.06-5.91,9.06s27,14,39,48,8.86,41,5.43,50-17.62,24.63-36,27.81,37.6-2.82,45.6-29.82c11.5-39.5-22.87-80.23-22.87-80.23S175,101,167.41,97.44Z"/><path class="cls-38-6" d="M91.5,140.5s-14,24-12,43,12,26,19,31,9.2,13.33,9.2,13.33S72,224,71.08,186.84c0,0,3.19-39.76,30.31-68.55L113.22,108S95.5,132.5,91.5,140.5Z"/><circle cx="138.5" cy="106.5" r="2"/><circle cx="111" cy="115" r="2"/><circle cx="164" cy="117" r="2"/><circle cx="185" cy="136" r="2"/><circle cx="194" cy="158" r="2"/><circle cx="201" cy="181" r="2"/><circle cx="200" cy="205" r="2"/><circle cx="183" cy="218" r="2"/><circle cx="164" cy="223" r="2"/><circle cx="142" cy="223" r="2"/><circle cx="118" cy="221" r="2"/><circle cx="97" cy="218" r="2"/><path class="cls-38-2" d="M139.5,98.5s35,3,57,48,13.83,72-24.08,85c-54.92,7-79.92-7-91-16.46-1.5.92-21-20.3-3-60.55C95.74,116,122.5,97.5,139.5,98.5Z"/></g>"""
),
Part(
""".cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""",
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M177,71s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S203,72,177,71Z"/><path class="cls-39-2" d="M135.5,78.5s1,4-5,9-20,12-25,31,0,29,2,37,7,25,6,35-4.75,16-4.75,16-21.38-60.92-12.82-89S129,81,135.5,78.5Z"/><path class="cls-39-3" d="M177,71s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S200.5,70.5,177,71Z"/><path class="cls-39-3" d="M177,70s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S200.5,69.5,177,70Z"/><path class="cls-39-4" d="M205.5,86.5s-27,2-29,28,9,73,9,73l5.38,23.67L231.5,193.5s-8-94-15-106C216.48,87.51,208.5,86.5,205.5,86.5Z"/><circle cx="147.5" cy="214.5" r="1.5"/><circle cx="165.5" cy="211.5" r="1.5"/><circle cx="183.5" cy="206.5" r="1.5"/><circle cx="196.5" cy="202.5" r="1.5"/><circle cx="208.5" cy="198.5" r="1.5"/><circle cx="219.5" cy="194.5" r="1.5"/><circle cx="228.5" cy="190.5" r="1.5"/><circle cx="130.5" cy="214.5" r="1.5"/><circle cx="115.5" cy="210.5" r="1.5"/>"""
)
)
private val mouths: List<Part> = listOf(
Part(
""".cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""",
"""<g id="mouth-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M123,183l-1,9a26.74,26.74,0,0,0,3.5.5c2,.44,22,.35,23-1l-1-9S132.5,181.5,123,183Z"/><path class="cls-40-2" d="M123,183l-1,9a26.74,26.74,0,0,0,3.5.5c2,.44,22,.35,23-1l-1-9S132.5,181.5,123,183Z"/><path d="M123.5,183.34s3.07,2.66,12.26,2.66S147,182.45,147,182.45a113.13,113.13,0,0,0-12.2-.28C129.63,182.45,124.52,182.45,123.5,183.34Z"/></g>"""
),
Part(
""".cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""",
"""<g id="mouth-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M126,180a129.54,129.54,0,0,1,3,25,19.93,19.93,0,0,0,10,1,24.23,24.23,0,0,0,8.5-3.5s-4-23-4-25c0,0-6,2-9,2-1.54,0-3.73.13-5.51.26S126,180,126,180Z"/><path class="cls-41-2" d="M126,180a129.54,129.54,0,0,1,3,25,19.93,19.93,0,0,0,10,1,24.23,24.23,0,0,0,8.5-3.5s-4-23-4-25c0,0-6,2-9,2-1.54,0-3.73.13-5.51.26S126,180,126,180Z"/><path class="cls-41-3" d="M131.5,190.71a113.49,113.49,0,0,1,1,12.25l-3,1.54s-1-14-2.28-18.07c-.72-2.86-1.15-6.11-1.15-6.11l3.43.18S130.5,182.55,131.5,190.71Z"/><path class="cls-41-3" d="M129.29,205.11l3.21-1.61a32,32,0,0,0,7-1,53.36,53.36,0,0,0,7.56-2.57l.44,2.57s-4.93,3.27-7,3.14S138.07,207.71,129.29,205.11Z"/><path class="cls-41-4" d="M131.81,193.42c.93.47,1.84.92,1.69-.92,0,0,0-2,1-2s7-1,7-1a1,1,0,0,1,1,1c0,1,0,3,1,2a3.44,3.44,0,0,1,2.12-1"/></g>"""
),
Part(
""".cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""",
"""<g id="mouth-03"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M170.5,168.5S151,177,141,179l-1,.2a112,112,0,0,1-32.68,1.74q-1.88-.18-3.82-.44l6,20a51.86,51.86,0,0,0,26,5c15-1,35-7,41-15Z"/><path class="cls-42-2" d="M110.5,199.5l2-2s-3-10-4-13-1.87-3.63-1.87-3.63l-3.13-.37,3.38,11.26,2.5,8.33Z"/><path class="cls-42-3" d="M118,181s3.13,7.7,4.06,9.85,2.31,5.09,2.12,7.12h0l.19,4a72.8,72.8,0,0,1,7.93.57C135,203,144,201,144,201a14.36,14.36,0,0,1-.15-3c.15-1-3.27-16.16-3.27-16.16l-1-2.27L135,180l-9.08,1.13Z"/><path class="cls-42-4" d="M155.48,174.52,159,185.15l2,9.85v2s10.25-4.55,12.63-6.27L176,189l-3.63-13.65-1.85-6.79Z"/><path class="cls-42-2" d="M113.5,198.5s2,3,14,4,35-6,35-6S174.56,191,176,188.77l.47,1.73s-7.38,11-40.69,15c0,0-16.31,1-26.31-5l3-3Z"/><path class="cls-42-5" d="M119.94,181.44s2.56,10.06,3.56,13.06a27.84,27.84,0,0,1,.88,7.67s-7.88.33-11.88-4.67l-5-15a5.38,5.38,0,0,0-1-1.7A80.36,80.36,0,0,0,119.94,181.44Z"/><path class="cls-42-5" d="M140.5,181.5c0,.14,4.14,18,3.56,19.5,0,0,15.93-2.76,17.18-4.13,0,0-1.75-10.37-2.75-13.37s-3-9-3-9-7.82,3-10.4,3.47a54.09,54.09,0,0,0-5.58,1.51A6,6,0,0,1,140.5,181.5Z"/><path class="cls-42-6" d="M170.5,168.5S151,177,141,179l-1,.2a112,112,0,0,1-32.68,1.74q-1.88-.18-3.82-.44l6,20a51.86,51.86,0,0,0,26,5c15-1,35-7,41-15Z"/></g>"""
),
Part(
""".cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""",
"""<g id="mouth-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M104,173s5.5,23.5,5.5,27.5c0,0,6,5,24,5s41-10,43-14c0,0-4-29-5-30,0,0-25,11-45,12-10.61.62-16.74.27-19.85-.08A20.06,20.06,0,0,1,104,173Z"/><path class="cls-43-2" d="M106.65,173.42s3.08,5.1,4.72,14.34,1,10.85,1,10.85S123,203,128,203a73,73,0,0,0,18-1.77,131.07,131.07,0,0,0,22.47-7.33l7.93-3.29-3.18-21.13-1.7-8s-26,11-45,12-20,0-20,0"/><path class="cls-43-3" d="M142.5,181.5V190l17.5-4,1,6v4.9l15-5.9-3-20.5C173.15,170.88,153.19,178.85,142.5,181.5Z"/><path class="cls-43-3" d="M142.4,190a38.22,38.22,0,0,0,2.1,11.5s-10.5,1.5-17,1S113,199,113,199l-2.5-15.5h11L123,191A122,122,0,0,0,142.4,190Z"/><path class="cls-43-3" d="M118,174l3.5,9c7.62.33,21-1.5,21-1.5s-1.13-7.27-2.5-9.5Z"/><path class="cls-43-4" d="M104,173s5.5,23.5,5.5,27.5c0,0,6,5,24,5s41-10,43-14c0,0-4-29-5-30,0,0-25,11-45,12-10.61.62-16.74.27-19.85-.08A20.06,20.06,0,0,1,104,173Z"/><path class="cls-43-5" d="M112.33,198.61S122,203,130,203s23.5-2.5,32.5-6.5,13.88-5.89,13.88-5.89l.12.89a31.45,31.45,0,0,1-8.77,5.75c-5.23,2.25-19.73,8.25-35.48,8.25s-22.75-5-22.75-5l2.83-1.89"/><path class="cls-43-5" d="M107.5,174.5a93.08,93.08,0,0,1,4,14,67.34,67.34,0,0,1,1,10l-3,2s-2.2-15.2-4.6-23.6l-.9-3.69,2.93.25Z"/><path class="cls-43-6" d="M124.61,202.46c.11-5-2.11-16-4.11-22s-3.42-6.66-3.42-6.66"/><path class="cls-43-6" d="M140.25,171.58s2.25,5.92,2.25,9.92v8s.33,10.83,2.67,11.92"/><path class="cls-43-6" d="M112,191s17,1,30-1,30-7,30-7l3-1"/><path class="cls-43-6" d="M142.5,181.5v8.42l17.51-3.7,1,8.28V194s-1-9-2-14-2.84-12.76-2.84-12.76"/><path class="cls-43-6" d="M176.5,190.5,173,170c-1.23,1.23-7.76,4-15.16,6.6-4.63,1.64-9.61,3.24-13.84,4.4-11,3-33.55,2.5-33.55,2.5"/></g>"""
),
Part(
""".cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""",
"""<g id="mouth-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M130.5,175.5v15a73.09,73.09,0,0,0,1,13s6,1,6,0-1-28-1-28C134.18,175.38,131.28,175.06,130.5,175.5Z"/><path class="cls-44-2" d="M130.5,175.5v15a73.09,73.09,0,0,0,1,13s6,1,6,0-1-28-1-28C134.18,175.38,131.28,175.06,130.5,175.5Z"/><path class="cls-44-3" d="M131.65,203a75.68,75.68,0,0,1-1.4-15.06,102.33,102.33,0,0,1,.48-12.27,3,3,0,0,1,1.25-.25,5.33,5.33,0,0,1,1,.1l.43.2h0s0,.16-.13.72a138.49,138.49,0,0,0-1,14.06,64.14,64.14,0,0,0,1,10.92Z"/><path d="M132,175.67h0a5,5,0,0,1,.87.08l.25.11a3.34,3.34,0,0,1-.09.52v.05a139.54,139.54,0,0,0-1,14.06,64.67,64.67,0,0,0,1,10.83l-1.16,1.16a77,77,0,0,1-1.3-14.56,107.4,107.4,0,0,1,.46-12.1,2.85,2.85,0,0,1,1-.17m0-.5a3.13,3.13,0,0,0-1.48.33s-.5,3-.5,12.44a72,72,0,0,0,1.5,15.56l2-2a62.68,62.68,0,0,1-1-11,138,138,0,0,1,1-14c.13-.56.15-.92,0-1l-.5-.23a5.57,5.57,0,0,0-1-.1Z"/><path class="cls-44-4" d="M137.19,201.56a5.59,5.59,0,0,0-2.48-.65,3.64,3.64,0,0,0-1.34.25,39.16,39.16,0,0,1-1.12-9c0-4.56,1.26-14.93,1.47-16.62l2.54.17.56,15.47Z"/><path d="M133.94,175.83,136,176l.56,15.25.35,9.94a5.61,5.61,0,0,0-2.21-.5,4,4,0,0,0-1.17.17,39,39,0,0,1-1-8.64c0-4.37,1.16-14.11,1.44-16.36m-.44-.53s-1.5,11.86-1.5,16.89a38.68,38.68,0,0,0,1.2,9.31,3.33,3.33,0,0,1,1.51-.35,5.56,5.56,0,0,1,2.74.85l-.38-10.8-.57-15.7-3-.2Z"/><path class="cls-44-5" d="M137.5,201.5v2l-5.7,0,1.7-2S134.5,199.5,137.5,201.5Z"/></g>"""
),
Part(
""".cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""",
"""<g id="mouth-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M149,179s-12,2-15,2-13.5,1.5-15.5,5.5-1,11.5,8,10.5,12-1.5,20-2.5,13-4,14-6,0-5-2-7S154.5,178.5,149,179Z"/><path class="cls-45-2" d="M149,179s-12,2-15,2-11.5.5-14.5,4.5c-4,4-2,11.5,7,11.5,8,0,12-1.5,20-2.5s13-4,14-6,0-5-2-7S154.5,178.5,149,179Z"/><path class="cls-45-3" d="M121.5,184.5s-3,4-1,6,4,3,12,2,13-3,18-3,10.83-2.26,9.42-6.13-5.75-5.12-8.58-4.5-12.7,1.53-16.26,2.08-6.7-.1-10.63,1.73S121.5,184.5,121.5,184.5Z"/></g>"""
),
Part(
""".cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""",
"""<g id="mouth-07"><g id="fill_color" data-name="fill color"><path id="background" class="cls-fill-1" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path id="background-2" data-name="background" class="cls-fill-1" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path id="background-3" data-name="background" class="cls-fill-1" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path id="background-4" data-name="background" class="cls-fill-1" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/></g><path class="cls-46-2" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path class="cls-46-3" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path class="cls-46-4" d="M124.5,180.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="125.85" y1="200.49" x2="127.5" y2="198.5"/><path class="cls-46-2" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path class="cls-46-3" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path class="cls-46-4" d="M133.5,179.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="134.85" y1="199.49" x2="136.5" y2="197.5"/><path class="cls-46-2" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path class="cls-46-3" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path class="cls-46-4" d="M143.5,179.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="144.85" y1="199.49" x2="146.5" y2="197.5"/><path class="cls-46-2" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/><path class="cls-46-3" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/><path class="cls-46-4" d="M151.5,178.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="152.85" y1="198.49" x2="154.5" y2="196.5"/></g>"""
),
Part(
""".cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""",
"""<g id="mouth-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148,175s-6.5.5-12.5,1.5a83.3,83.3,0,0,1-12,1v14c0,2,3,13,13,12s13-7,13-11-1-17-1-17h0S148.5,174.5,148,175Z"/><path class="cls-47-2" d="M148,175s-6.5.5-12.5,1.5a83.3,83.3,0,0,1-12,1v14c0,2,3,13,13,12s13-7,13-11-1-17-1-17h0S148.5,174.5,148,175Z"/><path class="cls-47-3" d="M129.59,177.21,130,191s1,7,7,8,12-.83,12.5-4.92-1-19.58-1-19.58l-10.88,1.67C135.5,176.5,129.59,177.21,129.59,177.21Z"/><path class="cls-47-4" d="M137.25,176.19l.22,22.88s-7-.57-7.39-7.64c-.58-4.93-.58-13.93-.58-13.93Z"/><path class="cls-47-5" d="M129.59,177.21,130,191s1,7,7,8,12-.83,12.5-4.92-1-19.58-1-19.58l-10.88,1.67C135.5,176.5,129.59,177.21,129.59,177.21Z"/></g>"""
),
Part(
""".cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""",
"""<g id="mouth-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M109.5,197.5c0,4,1,5,1,5h34c10,0,21-1,21-1v-23l-1-4h-2c-2,0-54.5.5-54.5.5v11Z"/><path class="cls-48-2" d="M163.5,174.5v25s-52,1.5-53.75,1l.61,1.79,28.9.21s19.65-.79,21.7-.65A9,9,0,0,0,165,201l.41-22.84S164,174,163.5,174.5Z"/><path class="cls-48-3" d="M109.5,197.5c0,4,1,5,1,5h34c10,0,21-1,21-1v-23l-1-4h-2c-2,0-54.5.5-54.5.5v11Z"/><path class="cls-48-4" d="M113.5,200.5h27c10,0,23-1,23-1v-24"/><line class="cls-48-5" x1="163.5" y1="199.5" x2="165" y2="201"/><path class="cls-48-4" d="M162,177v12"/><path class="cls-48-6" d="M112.5,177.5h27c9,0,20-1,20-1v20l-54,1a34.21,34.21,0,0,1,2-11A44.27,44.27,0,0,1,112.5,177.5Z"/><path class="cls-48-7" d="M159,176.5s-3,7.5-4,11.5a87,87,0,0,0-1.59,8.61l-12.91.19s-1-2.3,1-7.3,5-12,5-12S157.5,177,159,176.5Z"/><path class="cls-48-7" d="M128.5,196.89h-11a19.28,19.28,0,0,1,2-11.22c3-6.12,3.75-8.16,3.75-8.16h11.56s-1.31,3.06-2.31,6.12-4,6.12-4,10.2Z"/><path class="cls-48-8" d="M135.13,177.5H146.5l-5.43,13.25a8.08,8.08,0,0,0-.82,4c.25,1.89,0,2.25,0,2.25H128.5a23.82,23.82,0,0,1,.92-7.35c1.08-3.08,2.1-3,3.59-7.58S135.13,177.5,135.13,177.5Z"/></g>"""
),
Part(
""".cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""",
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-2" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-3" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-4" d="M108.33,195.83c0-.11-2.8-10.45-3.4-12.94a6.79,6.79,0,0,1-.3-2,2.1,2.1,0,0,1,.46,0,3,3,0,0,1,1.77.84c.32.85,3,8.16,4,11.88a31.56,31.56,0,0,1,1,5.64l-2.35.78Z"/><path d="M105.08,181.25h.09a2.55,2.55,0,0,1,1.36.68c.43,1.16,3,8.16,3.91,11.75a33.25,33.25,0,0,1,.95,5.29l-1.7.57-1-3.8c0-.1-2.8-10.45-3.4-12.93a13.31,13.31,0,0,1-.31-1.55h.1m0-.75c-.92,0-1.11,0-.52,2.48S108,195.93,108,195.93l1.2,4.57,3-1a28,28,0,0,0-1-6c-1-4-4-12-4-12a3.49,3.49,0,0,0-2-1Z"/><path class="cls-49-5" d="M111.5,199.5s17,6,36,2,29-11.87,29-11.87v.88l-.36.94-.44.64L174,193.33l-7,4.33L159.9,201l-10.68,3-4.63.79-5.34.51-5.75.16-5.06-.39L121,203.7,114.57,202l-5.07-1.54-.12-.67Z"/><path class="cls-49-2" d="M116.51,200.51c1-3,3-11,3-11s4,2,13,2,27-7,27-7,7,6,8,10"/>"""
)
)
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
@@ -29,6 +30,7 @@ import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight
import java.util.Base64
@Composable
@@ -45,9 +47,10 @@ fun RobohashAsyncImage(
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val context = LocalContext.current
val isLightTheme = MaterialTheme.colorScheme.isLight
val imageRequest = remember(robot) {
Robohash.imageRequest(context, robot)
RobohashImageRequest.build(context, robot, isLightTheme)
}
AsyncImage(
@@ -78,8 +81,9 @@ fun RobohashFallbackAsyncImage(
loadProfilePicture: Boolean
) {
val context = LocalContext.current
val isLightTheme = MaterialTheme.colorScheme.isLight
val painter = rememberAsyncImagePainter(
model = Robohash.imageRequest(context, robot)
model = RobohashImageRequest.build(context, robot, isLightTheme)
)
if (model != null && loadProfilePicture) {
@@ -90,12 +94,12 @@ fun RobohashFallbackAsyncImage(
}
if (isBase64) {
val painter = rememberAsyncImagePainter(
val base64Painter = rememberAsyncImagePainter(
model = Base64Requester.imageRequest(context, model)
)
Image(
painter = painter,
painter = base64Painter,
contentDescription = null,
modifier = modifier,
alignment = alignment,
@@ -129,33 +133,6 @@ fun RobohashFallbackAsyncImage(
}
}
@Composable
fun RobohashAsyncImageProxy(
robot: String,
model: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
loadProfilePicture: Boolean
) {
RobohashFallbackAsyncImage(
robot = robot,
model = model,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
loadProfilePicture = loadProfilePicture
)
}
object Base64Requester {
fun imageRequest(context: Context, message: String): ImageRequest {
return ImageRequest
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -43,6 +44,30 @@ fun TextSpinner(
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
TextSpinner(
placeholder,
options,
onSelect,
modifier
) { currentOption, modifier ->
OutlinedTextField(
value = currentOption,
onValueChange = {},
readOnly = true,
label = { label?.let { Text(it) } },
modifier = modifier
)
}
}
@Composable
fun TextSpinner(
placeholder: String,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
@@ -50,16 +75,16 @@ fun TextSpinner(
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier
modifier = modifier,
contentAlignment = Alignment.Center
) {
OutlinedTextField(
value = currentText,
onValueChange = {},
readOnly = true,
label = { label?.let { Text(it) } },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
mainElement(
currentText,
remember {
Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
}
)
Box(
modifier = Modifier
@@ -17,10 +17,12 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -35,6 +37,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -44,6 +47,8 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -67,6 +72,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
import com.vitorpamplona.amethyst.ui.note.MuteIcon
@@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
@@ -160,6 +167,8 @@ fun VideoView(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -168,22 +177,86 @@ fun VideoView(
) {
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
alwaysShowVideo = alwaysShowVideo,
accountViewModel = accountViewModel,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
)
}
if (blurhash == null) {
val ratio = aspectRatio(dimensions)
val modifier = if (ratio != null && roundedCorner && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
Box(modifier) {
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
}
}
} else {
val ratio = aspectRatio(dimensions)
val modifier = if (ratio != null && roundedCorner) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
Box(modifier, contentAlignment = Alignment.Center) {
if (!automaticallyStartPlayback.value) {
DisplayBlurHash(
blurhash,
null,
ContentScale.Crop,
MaterialTheme.colorScheme.imageModifier
)
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { automaticallyStartPlayback.value = true }
) {
DownloadForOfflineIcon(Size75dp, Color.White)
}
} else {
VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
}
}
}
}
@Composable
@@ -198,48 +271,15 @@ fun VideoViewInner(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
alwaysShowVideo: Boolean = false,
accountViewModel: AccountViewModel,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
)
}
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
val mediaItem = remember(videoUri) {
mutableStateOf(
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
)
}
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
@@ -250,6 +290,8 @@ fun VideoViewInner(
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
dimensions = dimensions,
blurhash = blurhash,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
keepPlaying = keepPlaying,
@@ -264,6 +306,49 @@ fun VideoViewInner(
}
}
@Composable
fun GetMediaItem(
videoUri: String,
title: String?,
artworkUri: String?,
authorName: String?,
inner: @Composable (State<MediaItem>) -> Unit
) {
val mediaItem = produceState<MediaItem?>(
initialValue = null,
key1 = videoUri
) {
this.value = MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
}
mediaItem.value?.let {
val myState = remember(videoUri) {
mutableStateOf(it)
}
inner(myState)
}
}
@Immutable
sealed class MediaControllerState {
@Immutable
@@ -279,7 +364,7 @@ sealed class MediaControllerState {
@Composable
@OptIn(UnstableApi::class)
fun GetVideoController(
mediaItem: MutableState<MediaItem>,
mediaItem: State<MediaItem>,
videoUri: String,
defaultToStart: Boolean = false,
nostrUriCallback: String? = null,
@@ -534,6 +619,8 @@ private fun RenderVideoPlayer(
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
dimensions: String? = null,
blurhash: String? = null,
topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
@@ -586,6 +673,8 @@ private fun RenderVideoPlayer(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
@@ -609,6 +698,19 @@ private fun RenderVideoPlayer(
}
}
val ratio = remember {
aspectRatio(dimensions)
}
if (ratio != null) {
DisplayBlurHash(
blurhash,
null,
ContentScale.Crop,
myModifier.aspectRatio(ratio)
)
}
AndroidView(
modifier = myModifier,
factory = factory
@@ -22,12 +22,15 @@ 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
@@ -56,6 +59,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
@@ -77,6 +81,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
@@ -99,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
@@ -145,7 +151,8 @@ class ZoomableUrlVideo(
dim: String? = null,
uri: String? = null,
val artworkUri: String? = null,
val authorName: String? = null
val authorName: String? = null,
val blurhash: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
@@ -182,7 +189,7 @@ class ZoomableLocalVideo(
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
@@ -246,6 +253,8 @@ fun ZoomableContentView(
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
@@ -481,13 +490,38 @@ private fun AddedImageFeatures(
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
val ratio = remember {
aspectRatio(content.dim)
}
if (!showImage.value) {
ImageUrlWithDownloadButton(content.uri, showImage)
if (content.blurhash != null && ratio != null) {
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
myModifier.aspectRatio(ratio).clickable {
showImage.value = true
}
)
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true }
) {
DownloadForOfflineIcon(Size75dp, Color.White)
}
} else {
ImageUrlWithDownloadButton(content.uri, showImage)
}
} else {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
if (ratio != null) {
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
} else {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
}
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
@@ -521,8 +555,29 @@ private fun AddedImageFeatures(
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
val ratio = remember {
aspectRatio(content.dim)
}
if (!showImage.value) {
ImageUrlWithDownloadButton(content.url, showImage)
if (content.blurhash != null && ratio != null) {
DisplayBlurHash(
content.blurhash,
content.description,
ContentScale.Crop,
myModifier.aspectRatio(ratio).clickable {
showImage.value = true
}
)
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true }
) {
DownloadForOfflineIcon(Size75dp, Color.White)
}
} else {
ImageUrlWithDownloadButton(content.url, showImage)
}
} else {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
@@ -531,7 +586,11 @@ private fun AddedImageFeatures(
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
if (ratio != null) {
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
} else {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
}
} else {
FlowRow(Modifier.fillMaxWidth()) {
DisplayUrlWithLoadingSymbol(content)
@@ -569,8 +628,9 @@ private fun AddedImageFeatures(
}
}
private fun aspectRatio(dim: String?): Float? {
fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
if (dim == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
@@ -578,7 +638,12 @@ private fun aspectRatio(dim: String?): Float? {
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
width / height
if (width < 0.1 || height < 0.1) {
null
} else {
width / height
}
} catch (e: Exception) {
null
}
@@ -664,7 +729,7 @@ private fun InlineLoadingIcon() =
}
@Composable
private fun DisplayBlurHash(
fun DisplayBlurHash(
blurhash: String?,
description: String?,
contentScale: ContentScale,
@@ -694,7 +759,6 @@ fun ZoomableImageDialog(
accountViewModel: AccountViewModel
) {
val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
Dialog(
onDismissRequest = onDismiss,
@@ -704,6 +768,8 @@ fun ZoomableImageDialog(
)
) {
val view = LocalView.current
val insets = ViewCompat.getRootWindowInsets(view)
val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
@@ -810,6 +876,7 @@ private fun DialogContent(
Row(
modifier = Modifier
.padding(10.dp)
.statusBarsPadding().systemBarsPadding()
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -947,6 +1014,10 @@ private fun RenderImageOrVideo(
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel
) {
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(true)
}
if (content is ZoomableUrlImage) {
val mainModifier = Modifier
.fillMaxSize()
@@ -968,16 +1039,15 @@ private fun RenderImageOrVideo(
)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(
VideoViewInner(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
alwaysShowVideo = true
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged
)
}
} else if (content is ZoomableLocalImage) {
@@ -1002,16 +1072,15 @@ private fun RenderImageOrVideo(
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let {
VideoView(
VideoViewInner(
videoUri = it.toUri().toString(),
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
alwaysShowVideo = true
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged
)
}
}
@@ -21,7 +21,7 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
.toSet()
}
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -20,19 +19,15 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
(
privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
me.hasSentMessagesTo(it)
) && !account.isAllHidden(it.users)
val knownChatrooms = me.privateChatrooms.filter {
(it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && !account.isAllHidden(it.key.users)
}
val privateMessages = messagingWith.mapNotNull { it ->
privateChatrooms[it]
?.roomMessages
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.lastOrNull { it.event != null }
val privateMessages = knownChatrooms.mapNotNull { it ->
it.value
.roomMessages
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
}
val publicChannels = account.selectedChatsFollowList().mapNotNull {
@@ -50,58 +45,53 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
val me = account.userProfile()
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPublicMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key == oldNote.channelHex()) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (newNotePair.key == oldRoom) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
sort(myNewList.toSet()).take(1000)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return oldList
}
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
var myNewList = oldList
newRelevantPublicMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key == oldNote.channelHex()) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (newNotePair.key == oldRoom) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
return sort(myNewList.toSet()).take(1000)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -19,17 +18,15 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
privateChatrooms[it]?.senderIntersects(followingKeySet) == false &&
!me.hasSentMessagesTo(it) && !account.isAllHidden(it.users)
val newChatrooms = me.privateChatrooms.filter {
!it.value.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(it.key) && !account.isAllHidden(it.key.users)
}
val privateMessages = messagingWith.mapNotNull { it ->
privateChatrooms[it]
?.roomMessages
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.lastOrNull { it.event != null }
val privateMessages = newChatrooms.mapNotNull { it ->
it.value
.roomMessages
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
}
return privateMessages
@@ -38,40 +35,35 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
}
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
val me = account.userProfile()
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (newNotePair.key == oldRoom) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
sort(myNewList.toSet()).take(1000)
if (newRelevantPrivateMessages.isEmpty()) {
return oldList
}
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
var myNewList = oldList
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (newNotePair.key == oldRoom) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
return sort(myNewList.toSet()).take(1000)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
@@ -0,0 +1,72 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverMarketplaceFeedFilter(
val account: Account
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + followList()
}
open fun followList(): String {
return account.defaultDiscoveryFollowList.value
}
override fun showHiddenKey(): Boolean {
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
}
override fun feed(): List<Note> {
val classifieds = LocalCache.addressables
.filter { it.value.event is ClassifiedsEvent }
.map { it.value }
val notes = innerApplyFilter(classifieds)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
val isHiddenList = showHiddenKey()
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
val activities = collection
.asSequence()
.filter {
it.event is ClassifiedsEvent &&
it.event?.hasTagWithContent("image") == true &&
it.event?.hasTagWithContent("price") == true &&
it.event?.hasTagWithContent("title") == true
}
.filter {
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true
}
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -56,7 +56,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
.asSequence()
.filter { it ->
val noteEvent = it.event
val isGlobalRelay = it.relays.any { gRelays.contains(it) }
val isGlobalRelay = it.relays.any { gRelays.contains(it.url) }
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent) &&
(!ignoreAddressables || noteEvent.kind() < 10000) &&
((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeohashSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
@@ -0,0 +1,93 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
@Composable
@Preview
fun AddButtonPreview() {
ThemeComparison(
onDark = {
Row() {
Column {
AddButton(isActive = true) {}
AddButton(isActive = false) {}
}
Column {
RemoveButton(isActive = true) {}
RemoveButton(isActive = false) {}
}
}
},
onLight = {
Row() {
Column {
AddButton(isActive = true) {}
AddButton(isActive = false) {}
}
Column {
RemoveButton(isActive = true) {}
RemoveButton(isActive = false) {}
}
}
}
)
}
@Composable
fun AddButton(
text: Int = R.string.add,
isActive: Boolean = true,
modifier: Modifier = Modifier.padding(start = 3.dp),
onClick: () -> Unit
) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onClick()
}
},
shape = ButtonBorder,
enabled = isActive,
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun RemoveButton(
isActive: Boolean = true,
onClick: () -> Unit
) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = {
if (isActive) {
onClick()
}
},
shape = ButtonBorder,
enabled = isActive,
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.remove), color = Color.White)
}
}
@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@Composable
fun DisplayFollowingCommunityInPost(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(HalfStartPadding) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayCommunity(baseNote, nav)
}
}
}
@Composable
private fun DisplayCommunity(note: Note, nav: (String) -> Unit) {
val communityTag = remember(note) {
note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.kind)
} ?: return
val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) }
val route = remember(note) { "Community/${communityTag.toTag()}" }
ClickableText(
text = displayTag,
onClick = { nav(route) },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.primary.copy(
alpha = 0.52f
)
),
maxLines = 1
)
}
private fun getCommunityShortName(communityTag: ATag): String {
val name = if (communityTag.dTag.length > 10) {
communityTag.dTag.take(10) + "..."
} else {
communityTag.dTag.take(10)
}
return "/n/$name"
}
@@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun DisplayFollowingHashtagsInPost(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = remember { baseNote.event } ?: return
val userFollowState by accountViewModel.userFollows.observeAsState()
var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = userFollowState) {
launch(Dispatchers.Default) {
val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags)
if (firstTag != newFirstTag) {
launch(Dispatchers.Main) {
firstTag = newFirstTag
}
}
}
}
firstTag?.let {
Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayTagList(it, nav)
}
}
}
}
@Composable
private fun DisplayTagList(firstTag: String, nav: (String) -> Unit) {
val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") }
val route = remember(firstTag) { "Hashtag/$firstTag" }
ClickableText(
text = displayTag,
onClick = { nav(route) },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.primary.copy(
alpha = 0.52f
)
),
maxLines = 1
)
}
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
@Composable
@Preview
fun DisplayPoWPreview() {
ThemeComparison(
onDark = {
DisplayPoW(pow = 24)
},
onLight = {
DisplayPoW(pow = 24)
}
)
}
@Composable
fun DisplayPoW(
pow: Int
) {
val powStr = remember(pow) {
"PoW-$pow"
}
Text(
powStr,
color = MaterialTheme.colorScheme.lessImportantLink,
fontSize = Font14SP,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@@ -9,19 +10,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
@@ -35,10 +43,102 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
@Stable
data class Reward(val amount: BigDecimal)
@Composable
fun DisplayReward(
baseReward: Reward,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var popupExpanded by remember { mutableStateOf(false) }
Column() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { popupExpanded = true }
) {
ClickableText(
text = AnnotatedString("#bounty"),
onClick = { nav("Hashtag/bounty") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.primary.copy(
alpha = 0.52f
)
)
)
RenderPledgeAmount(baseNote, baseReward, accountViewModel)
}
if (popupExpanded) {
AddBountyAmountDialog(baseNote, accountViewModel) {
popupExpanded = false
}
}
}
}
@Composable
private fun RenderPledgeAmount(
baseNote: Note,
baseReward: Reward,
accountViewModel: AccountViewModel
) {
val repliesState by baseNote.live().replies.observeAsState()
var reward by remember {
mutableStateOf<String>(
showAmount(baseReward.amount)
)
}
var hasPledge by remember {
mutableStateOf<Boolean>(
false
)
}
LaunchedEffect(key1 = repliesState) {
launch(Dispatchers.IO) {
repliesState?.note?.pledgedAmountByOthers()?.let {
val newRewardAmount = showAmount(baseReward.amount.add(it))
if (newRewardAmount != reward) {
reward = newRewardAmount
}
}
val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true
if (hasPledge != newHasPledge) {
launch(Dispatchers.Main) {
hasPledge = newHasPledge
}
}
}
}
if (hasPledge) {
ZappedIcon(modifier = Size20Modifier)
} else {
ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText)
}
Text(
text = reward,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1
)
}
class AddBountyAmountViewModel : ViewModel() {
private var account: Account? = null
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayUncitedHashtags(
hashtags: ImmutableList<String>,
eventContent: String,
nav: (String) -> Unit
) {
val unusedHashtags = remember(eventContent) {
hashtags.filter { !eventContent.contains(it, true) }
}
if (unusedHashtags.isNotEmpty()) {
FlowRow(
modifier = HalfTopPadding
) {
unusedHashtags.forEach { hashtag ->
ClickableText(
text = remember { AnnotatedString("#$hashtag ") },
onClick = { nav("Hashtag/$hashtag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.lessImportantLink
)
)
}
}
}
}
@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.ui.elements
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.events.EventInterface
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
if (list.isEmpty()) return
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange
)
}
Spacer(modifier = StdHorzSpacer)
FlowRow {
list.forEach {
if (it.isLnAddress) {
ClickableText(
text = AnnotatedString(it.lnAddressOrPubKeyHex),
onClick = { },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary)
)
} else {
UserPicture(
userHex = it.lnAddressOrPubKeyHex,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
}
@@ -0,0 +1,126 @@
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Divider
import androidx.compose.material3.ListItem
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
import com.vitorpamplona.amethyst.ui.note.TimeAgo
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.quartz.utils.TimeUtils
@Composable
@Preview
fun ChannelNamePreview() {
Column {
ChatHeaderLayout(
channelPicture = {
Image(
painter = painterResource(R.drawable.github),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth
)
},
firstRow = {
Text("This is my author", Modifier.weight(1f))
TimeAgo(TimeUtils.now())
},
secondRow = {
Text("This is a message from this person", Modifier.weight(1f))
NewItemsBubble()
},
onClick = {
}
)
Divider()
ListItem(
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("This is my author", Modifier.weight(1f))
TimeAgo(TimeUtils.now())
}
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("This is a message from this person", Modifier.weight(1f))
NewItemsBubble()
}
},
leadingContent = {
Image(
painter = painterResource(R.drawable.github),
contentDescription = stringResource(id = R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Size55Modifier
)
}
)
}
}
@Composable
fun ChatHeaderLayout(
channelPicture: @Composable () -> Unit,
firstRow: @Composable RowScope.() -> Unit,
secondRow: @Composable RowScope.() -> Unit,
onClick: () -> Unit
) {
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
Row(
modifier = ChatHeadlineBorders,
verticalAlignment = Alignment.CenterVertically
) {
Box(Size55Modifier) {
channelPicture()
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
firstRow()
}
Spacer(modifier = Height4dpModifier)
Row(
verticalAlignment = Alignment.CenterVertically
) {
secondRow()
}
}
}
Divider(
modifier = StdTopPadding,
thickness = DividerThickness
)
}
}
@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.RepostedIcon
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
@Preview
private fun GenericRepostSectionPreview() {
GenericRepostLayout(
baseAuthorPicture = {
Text("ab")
},
repostAuthorPicture = {
Text("cd")
}
)
}
@Composable
fun GenericRepostLayout(
baseAuthorPicture: @Composable () -> Unit,
repostAuthorPicture: @Composable () -> Unit
) {
Box(modifier = Size55Modifier) {
Box(remember { Size35Modifier.align(Alignment.TopStart) }) {
baseAuthorPicture()
}
Box(
remember {
Size18Modifier
.align(Alignment.BottomStart)
.padding(1.dp)
}
) {
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText)
}
Box(
remember { Size35Modifier.align(Alignment.BottomEnd) },
contentAlignment = Alignment.BottomEnd
) {
repostAuthorPicture()
}
}
}
@@ -48,7 +48,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -220,8 +220,8 @@ private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) {
private fun AccountPicture(user: User, loadProfilePicture: Boolean) {
val profilePicture by user.live().profilePictureChanges.observeAsState()
RobohashAsyncImageProxy(
robot = remember(user) { user.pubkeyHex },
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = profilePicture,
contentDescription = stringResource(R.string.profile_image),
modifier = AccountPictureModifier,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -29,7 +30,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -53,23 +53,30 @@ enum class Keyboard {
Opened, Closed
}
fun isKeyboardOpen(view: View): Keyboard {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
return if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
}
@Composable
fun keyboardAsState(): State<Keyboard> {
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
val view = LocalView.current
val keyboardState = remember(view) {
mutableStateOf(isKeyboardOpen(view))
}
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
val newKeyboardValue = isKeyboardOpen(view)
if (newKeyboardValue != keyboardState.value) {
keyboardState.value = newKeyboardValue
@@ -89,8 +96,8 @@ fun keyboardAsState(): State<Keyboard> {
fun IfKeyboardClosed(
inner: @Composable () -> Unit
) {
val isKeyboardOpen by keyboardAsState()
if (isKeyboardOpen == Keyboard.Closed) {
val isKeyboardState by keyboardAsState()
if (isKeyboardState == Keyboard.Closed) {
inner()
}
}
@@ -135,49 +142,45 @@ private fun RowScope.HasNewItemsIcon(
NavigationBarItem(
icon = {
ObserveNewItems(route, accountViewModel) { hasNewItems ->
NotifiableIcon(
route.icon,
route.notifSize,
route.iconSize,
selected,
hasNewItems
)
}
NotifiableIcon(
selected,
route,
accountViewModel
)
},
selected = selected,
onClick = { nav(route, selected) }
)
}
@Composable
fun ObserveNewItems(route: Route, accountViewModel: AccountViewModel, inner: @Composable (hasNewItems: State<Boolean>?) -> Unit) {
val hasNewItems = accountViewModel.notificationDots.hasNewItems[route]?.collectAsStateWithLifecycle()
inner(hasNewItems)
}
@Composable
private fun NotifiableIcon(
icon: Int,
size: Dp,
iconSize: Dp,
selected: Boolean,
hasNewItems: State<Boolean>?
route: Route,
accountViewModel: AccountViewModel
) {
Box(remember { Modifier.size(size) }) {
Box(route.notifSize) {
Icon(
painter = painterResource(id = icon),
painter = painterResource(id = route.icon),
contentDescription = null,
modifier = remember { Modifier.size(iconSize) },
modifier = route.iconSize,
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified
)
if (hasNewItems?.value == true) {
NotificationDotIcon(
Modifier.align(Alignment.TopEnd)
)
}
AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd))
}
}
@Composable
fun AddNotifIconIfNeeded(
route: Route,
accountViewModel: AccountViewModel,
modifier: Modifier
) {
val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return
val hasNewItems by flow.collectAsStateWithLifecycle()
if (hasNewItems) {
NotificationDotIcon(modifier)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
@@ -61,6 +62,7 @@ fun AppNavigation(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
@@ -137,6 +139,7 @@ fun AppNavigation(
Route.Discover.let { route ->
composable(route.route, route.arguments, content = {
DiscoverScreen(
discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
@@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.AmethystIcon
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
@@ -526,7 +527,7 @@ private fun LoggedInUserPictureDrawer(
IconButton(
onClick = onClick
) {
RobohashAsyncImageProxy(
RobohashFallbackAsyncImage(
robot = pubkeyHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
@@ -725,12 +726,12 @@ fun SimpleTextSpinner(
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Spacer(modifier = Size20Modifier)
Text(currentText)
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(20.dp),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.placeholderText
)
}
@@ -809,7 +810,7 @@ fun RenderOption(option: Name) {
modifier = Modifier.fillMaxWidth()
) {
val name by option.note.live().metadata.map {
"/n/" + (it.note as? AddressableNote)?.dTag()
"/n/" + ((it.note as? AddressableNote)?.dTag() ?: "")
}.observeAsState()
Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface)
@@ -67,19 +67,28 @@ import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Font18SP
import com.vitorpamplona.amethyst.ui.theme.IconRowModifier
import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.bannerModifier
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -90,6 +99,15 @@ fun DrawerContent(
openSheet: () -> Unit,
accountViewModel: AccountViewModel
) {
val coroutineScope = rememberCoroutineScope()
val onClickUser = {
nav("User/${accountViewModel.userProfile().pubkeyHex}")
coroutineScope.launch {
drawerState.close()
}
Unit
}
val automaticallyShowProfilePicture = remember {
accountViewModel.settings.showProfilePictures.value
}
@@ -100,27 +118,31 @@ fun DrawerContent(
) {
Column() {
ProfileContent(
accountViewModel.account.userProfile(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 70.dp),
drawerState,
baseAccountUser = accountViewModel.account.userProfile(),
modifier = profileContentHeaderModifier,
accountViewModel,
nav
onClickUser
)
Column(drawerSpacing) {
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel)
}
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
Divider(
thickness = DividerThickness,
modifier = Modifier.padding(top = 20.dp)
)
ListContent(
nav,
drawerState,
openSheet,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
accountViewModel
drawerState,
openSheet,
accountViewModel,
nav
)
BottomContent(
@@ -137,50 +159,53 @@ fun DrawerContent(
fun ProfileContent(
baseAccountUser: User,
modifier: Modifier = Modifier,
drawerState: DrawerState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
onClickUser: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState()
val accountUserState by baseAccountUser.live().metadata.observeAsState()
val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return
val profileBanner = remember(accountUserState) { accountUserState?.user?.info?.banner?.ifBlank { null } }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
// val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.toBestDisplayName() }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
val automaticallyShowProfilePicture = remember {
accountViewModel.settings.showProfilePictures.value
}
ProfileContentTemplate(
profilePubHex = baseAccountUser.pubkeyHex,
profileBanner = userInfo?.banner,
profilePicture = userInfo?.profilePicture(),
bestDisplayName = userInfo?.bestDisplayName(),
tags = userInfo?.tags,
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClickUser
)
}
@Composable
fun ProfileContentTemplate(
profilePubHex: HexKey,
profileBanner: String?,
profilePicture: String?,
bestDisplayName: String?,
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
accountViewModel: AccountViewModel,
onClick: () -> Unit
) {
Box {
if (profileBanner != null) {
AsyncImage(
model = profileBanner,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
modifier = bannerModifier
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
modifier = bannerModifier
)
}
Column(modifier = modifier) {
RobohashAsyncImageProxy(
RobohashFallbackAsyncImage(
robot = profilePubHex,
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
@@ -190,13 +215,8 @@ fun ProfileContent(
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
.background(MaterialTheme.colorScheme.background)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
drawerState.close()
}
}),
loadProfilePicture = automaticallyShowProfilePicture
.clickable(onClick = onClick),
loadProfilePicture = accountViewModel.settings.showProfilePictures.value
)
if (bestDisplayName != null) {
@@ -205,139 +225,98 @@ fun ProfileContent(
tags = tags,
modifier = Modifier
.padding(top = 7.dp)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
drawerState.close()
}
}),
.clickable(onClick = onClick),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(Modifier.padding(top = Size10dp)) {
EditStatusBoxes(baseAccountUser, accountViewModel)
}
Row(
modifier = Modifier
.padding(top = Size10dp)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
drawerState.close()
}
})
) {
FollowingAndFollowerCounts(baseAccountUser)
}
}
}
}
@Composable
private fun EditStatusBoxes(baseAccountUser: User, accountViewModel: AccountViewModel) {
val focusManager = LocalFocusManager.current
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
if (statuses.isEmpty()) {
val currentStatus = remember {
mutableStateOf("")
}
val hasChanged by remember {
derivedStateOf {
currentStatus.value != ""
}
}
OutlinedTextField(
value = currentStatus.value,
onValueChange = { currentStatus.value = it },
label = { Text(text = stringResource(R.string.status_update)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.status_update),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
SendButton() {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
}
}
)
StatusEditBar(accountViewModel = accountViewModel)
} else {
statuses.forEach {
val originalStatus by it.live().content.observeAsState("")
val originalStatus by it.live().content.observeAsState()
val thisStatus = remember {
mutableStateOf(originalStatus)
}
val hasChanged by remember {
derivedStateOf {
thisStatus.value != originalStatus
}
}
OutlinedTextField(
value = thisStatus.value,
onValueChange = { thisStatus.value = it },
label = { Text(text = stringResource(R.string.status_update)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.status_update),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged) {
SendButton() {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
} else {
UserStatusDeleteButton() {
accountViewModel.deleteStatus(it)
focusManager.clearFocus(true)
}
}
}
)
StatusEditBar(originalStatus, it.address, accountViewModel)
}
}
}
}
@Composable
fun StatusEditBar(
savedStatus: String? = null,
tag: ATag? = null,
accountViewModel: AccountViewModel
) {
val focusManager = LocalFocusManager.current
val currentStatus = remember {
mutableStateOf(savedStatus ?: "")
}
val hasChanged = remember {
derivedStateOf {
currentStatus.value != (savedStatus ?: "")
}
}
OutlinedTextField(
value = currentStatus.value,
onValueChange = { currentStatus.value = it },
label = { Text(text = stringResource(R.string.status_update)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.status_update),
color = MaterialTheme.colorScheme.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
if (tag == null) {
accountViewModel.createStatus(currentStatus.value)
} else {
accountViewModel.updateStatus(tag, currentStatus.value)
}
focusManager.clearFocus(true)
}
),
singleLine = true,
trailingIcon = {
if (hasChanged.value) {
SendButton {
if (tag == null) {
accountViewModel.createStatus(currentStatus.value)
} else {
accountViewModel.updateStatus(tag, currentStatus.value)
}
focusManager.clearFocus(true)
}
} else {
if (tag != null) {
UserStatusDeleteButton {
accountViewModel.deleteStatus(tag)
focusManager.clearFocus(true)
}
}
}
}
)
}
@Composable
fun SendButton(onClick: () -> Unit) {
IconButton(
@@ -369,7 +348,7 @@ fun UserStatusDeleteButton(onClick: () -> Unit) {
}
@Composable
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
private fun FollowingAndFollowerCounts(baseAccountUser: User, onClick: () -> Unit) {
var followingCount by remember { mutableStateOf("--") }
var followerCount by remember { mutableStateOf("--") }
@@ -385,21 +364,25 @@ private fun FollowingAndFollowerCounts(baseAccountUser: User) {
}
}
Text(
text = followingCount,
fontWeight = FontWeight.Bold
)
Row(
modifier = drawerSpacing.clickable(onClick = onClick)
) {
Text(
text = followingCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.following))
Text(stringResource(R.string.following))
Spacer(modifier = DoubleHorzSpacer)
Spacer(modifier = DoubleHorzSpacer)
Text(
text = followerCount,
fontWeight = FontWeight.Bold
)
Text(
text = followerCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.followers))
Text(stringResource(R.string.followers))
}
}
@Composable
@@ -426,20 +409,18 @@ fun WatchFollower(baseAccountUser: User, onReady: (String) -> Unit) {
@Composable
fun ListContent(
nav: (String) -> Unit,
modifier: Modifier,
drawerState: DrawerState,
openSheet: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val route = remember(accountViewModel) {
"User/${accountViewModel.userProfile().pubkeyHex}"
}
val coroutineScope = rememberCoroutineScope()
var wantsToEditRelays by remember {
mutableStateOf(false)
}
var wantsToEditRelays by remember { mutableStateOf(false) }
var backupDialogOpen by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) }
@@ -504,9 +485,8 @@ fun ListContent(
)
}
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
IconRow(
title = textTorProxy,
title = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short),
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
onLongClick = {
@@ -672,21 +652,19 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
modifier = IconRowModifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(icon),
null,
modifier = Modifier.size(22.dp),
modifier = Size22Modifier,
tint = tint
)
Text(
modifier = Modifier.padding(start = 16.dp),
modifier = IconRowTextModifier,
text = title,
fontSize = 18.sp
fontSize = Font18SP
)
}
}
@@ -0,0 +1,67 @@
package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.collections.immutable.persistentSetOf
import java.net.URLEncoder
fun routeFor(note: Note, loggedIn: User): String? {
val noteEvent = note.event
if (noteEvent is ChannelMessageEvent || noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) {
note.channelHex()?.let {
return "Channel/$it"
}
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${URLEncoder.encode(note.idHex, "utf-8")}"
} else {
return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
}
return null
}
fun routeToMessage(user: HexKey, draftMessage: String?, accountViewModel: AccountViewModel): String {
val withKey = ChatroomKey(persistentSetOf(user))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
"Room/${withKey.hashCode()}?message=$draftMessage"
} else {
"Room/${withKey.hashCode()}"
}
}
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel)
}
fun routeFor(note: Channel): String {
return "Channel/${note.idHex}"
}
fun routeFor(user: User): String {
return "User/${user.pubkeyHex}"
}
fun authorRouteFor(note: Note): String {
return "User/${note.author?.pubkeyHex}"
}
@@ -1,11 +1,12 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.Modifier
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
@@ -37,16 +38,16 @@ sealed class Route(
val route: String,
val base: String = route.substringBefore("?"),
val icon: Int,
val notifSize: Dp = Size23dp,
val iconSize: Dp = Size20dp,
val notifSize: Modifier = Modifier.size(Size23dp),
val iconSize: Modifier = Modifier.size(Size20dp),
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ -> false },
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
) {
object Home : Route(
route = "Home?nip47={nip47}",
icon = R.drawable.ic_home,
notifSize = Size25dp,
iconSize = Size24dp,
notifSize = Modifier.size(Size25dp),
iconSize = Modifier.size(Size24dp),
arguments = listOf(
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
).toImmutableList(),
@@ -335,8 +336,9 @@ private fun getRouteWithArguments(
): String? {
var route = destination.route ?: return null
arguments?.let { bundle ->
destination.arguments.keys.forEach { key ->
val value = destination.arguments[key]?.type?.get(bundle, key)?.toString()
destination.arguments.forEach {
val key = it.key
val value = it.value.type[bundle, key]?.toString()
if (value == null) {
val keyStart = route.indexOf("{$key}")
// if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, `&` or `&`
@@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
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.lifecycle.distinctUntilChanged
@@ -58,23 +62,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.Price
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -95,7 +104,7 @@ fun ChannelCardCompose(
) {
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = hasEvent) {
Crossfade(targetState = hasEvent, label = "ChannelCardCompose") {
if (it) {
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
CheckHiddenChannelCardCompose(
@@ -155,7 +164,7 @@ fun CheckHiddenChannelCardCompose(
note.isHiddenFor(it)
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
Crossfade(targetState = isHidden) {
Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") {
if (!it) {
LoadedChannelCardCompose(
note,
@@ -195,7 +204,7 @@ fun LoadedChannelCardCompose(
}
}
Crossfade(targetState = state) {
Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") {
RenderChannelCardReportState(
it,
note,
@@ -220,7 +229,7 @@ fun RenderChannelCardReportState(
) {
var showReportedNote by remember { mutableStateOf(false) }
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote ->
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
@@ -334,6 +343,28 @@ fun InnerChannelCardWithReactions(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (remember { baseNote.event }) {
is LiveActivitiesEvent -> {
InnerCardRow(baseNote, accountViewModel, nav)
}
is CommunityDefinitionEvent -> {
InnerCardRow(baseNote, accountViewModel, nav)
}
is ChannelCreateEvent -> {
InnerCardRow(baseNote, accountViewModel, nav)
}
is ClassifiedsEvent -> {
InnerCardBox(baseNote, accountViewModel, nav)
}
}
}
@Composable
fun InnerCardRow(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(StdPadding) {
SensitivityWarning(
@@ -353,6 +384,22 @@ fun InnerChannelCardWithReactions(
)
}
@Composable
fun InnerCardBox(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(HalfPadding) {
SensitivityWarning(
note = baseNote,
accountViewModel = accountViewModel
) {
RenderClassifiedsThumb(baseNote, accountViewModel, nav)
}
}
}
@Composable
private fun RenderNoteRow(
baseNote: Note,
@@ -372,6 +419,116 @@ private fun RenderNoteRow(
}
}
@Immutable
data class ClassifiedsThumb(
val image: String?,
val title: String?,
val price: Price?
)
@Composable
fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? ClassifiedsEvent ?: return
val card by baseNote.live().metadata.map {
val noteEvent = it.note.event as? ClassifiedsEvent
ClassifiedsThumb(
image = noteEvent?.image(),
title = noteEvent?.title(),
price = noteEvent?.price()
)
}.distinctUntilChanged().observeAsState(
ClassifiedsThumb(
image = noteEvent.image(),
title = noteEvent.title(),
price = noteEvent.price()
)
)
RenderClassifiedsThumb(card, baseNote.author)
}
@Preview
@Composable
fun RenderClassifiedsThumbPreview() {
Surface(Modifier.size(200.dp)) {
RenderClassifiedsThumb(
card = ClassifiedsThumb(
image = null,
title = "Like New",
price = Price("800000", "SATS", null)
),
author = null
)
}
}
@Composable
fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentAlignment = BottomStart
) {
card.image?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} ?: run {
author?.let {
DisplayAuthorBanner(it)
}
}
Row(
Modifier
.fillMaxWidth()
.background(Color.Black.copy(0.6f))
.padding(Size5dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
card.title?.let {
Text(
text = it,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
modifier = Modifier.weight(1f)
)
}
card.price?.let {
val priceTag = remember(card) {
val newAmount = it.amount.toBigDecimalOrNull()?.let {
showAmountAxis(it)
} ?: it.amount
if (it.frequency != null && it.currency != null) {
"$newAmount ${it.currency}/${it.frequency}"
} else if (it.currency != null) {
"$newAmount ${it.currency}"
} else {
newAmount
}
}
Text(
text = priceTag,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White
)
}
}
}
}
@Immutable
data class LiveActivityCard(
val name: String,
@@ -2,19 +2,13 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -50,16 +44,12 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.emptyLineItemModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.HexKey
@@ -81,7 +71,7 @@ fun ChatroomHeaderCompose(
if (hasEvent) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
BlankNote(Modifier)
BlankNote()
}
}
@@ -116,15 +106,11 @@ private fun ChatroomPrivateMessages(
}
}
Crossfade(userRoom) { room ->
Crossfade(userRoom, label = "ChatroomPrivateMessages") { room ->
if (room != null) {
UserRoomCompose(baseNote, room, accountViewModel, nav)
} else {
Box(
Modifier
.height(Size75dp)
.fillMaxWidth()
) {
Box(emptyLineItemModifier) {
// Makes sure just a max amount of objects are loaded.
}
}
@@ -202,7 +188,7 @@ private fun ChannelRoomCompose(
ChannelTitleWithLabelInfo(channelName, modifier)
},
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { "$authorName: $description" },
channelLastContent = remember(note, authorState) { "$authorName: $description" },
hasNewMessages = hasNewMessages,
loadProfilePicture = automaticallyShowProfilePicture,
onClick = { nav(route) }
@@ -295,8 +281,8 @@ fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: Acc
it.user.privateChatrooms[room]?.subject
}.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
Crossfade(targetState = roomSubject, modifier) {
if (it != null && it.isNotBlank()) {
Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") {
if (!it.isNullOrBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
@@ -408,7 +394,7 @@ private fun WatchNotificationChanges(
accountViewModel: AccountViewModel,
onNewStatus: (Boolean) -> Unit
) {
LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.value) {
LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) {
launch(Dispatchers.IO) {
note.event?.createdAt()?.let {
val lastTime = accountViewModel.account.loadLastRead(route)
@@ -450,16 +436,11 @@ fun ChannelName(
) {
ChannelName(
channelPicture = {
RobohashAsyncImageProxy(
RobohashFallbackAsyncImage(
robot = channelIdHex,
model = channelPicture,
contentDescription = stringResource(R.string.channel_image),
modifier = remember {
Modifier
.width(Size55dp)
.height(Size55dp)
.clip(shape = CircleShape)
},
modifier = AccountPictureModifier,
loadProfilePicture = loadProfilePicture
)
},
@@ -480,76 +461,38 @@ fun ChannelName(
hasNewMessages: MutableState<Boolean>,
onClick: () -> Unit
) {
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
Row(modifier = ChatHeadlineBorders) {
Column(Size55Modifier) {
channelPicture()
ChatHeaderLayout(
channelPicture = channelPicture,
firstRow = {
channelTitle(Modifier.weight(1f))
TimeAgo(channelLastTime)
},
secondRow = {
if (channelLastContent != null) {
Text(
channelLastContent,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f)
)
} else {
Text(
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceAround
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) })
}
Spacer(modifier = Height4dpModifier)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
SecondRow(channelLastContent, hasNewMessages, remember { Modifier.weight(1f) })
}
if (hasNewMessages.value) {
NewItemsBubble()
}
}
Divider(
modifier = StdTopPadding,
thickness = DividerThickness
)
}
}
@Composable
private fun SecondRow(channelLastContent: String?, hasNewMessages: MutableState<Boolean>, modifier: Modifier) {
if (channelLastContent != null) {
Text(
channelLastContent,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = modifier
)
} else {
Text(
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
if (hasNewMessages.value) {
NewItemsBubble()
}
}
@Composable
private fun FirstRow(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
modifier: Modifier
) {
channelTitle(modifier)
TimeAgo(channelLastTime)
},
onClick = onClick
)
}
@Composable
@@ -557,10 +500,8 @@ private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return
val context = LocalContext.current
val timeAgo by remember(channelLastTime) {
derivedStateOf {
timeAgo(channelLastTime, context)
}
val timeAgo = remember(channelLastTime) {
timeAgo(channelLastTime, context)
}
Text(
text = timeAgo,
@@ -48,7 +48,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -450,7 +450,7 @@ private fun MessageBubbleLines(
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSize = Size15dp
iconSizeModifier = Size15Modifier
) {
onWantsToReply(baseNote)
}
@@ -784,7 +784,7 @@ private fun UserIcon(
nav: (String) -> Unit,
route: String
) {
RobohashAsyncImageProxy(
RobohashFallbackAsyncImage(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
@@ -6,12 +6,14 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.VolumeOff
@@ -76,12 +78,12 @@ fun MessageIcon(modifier: Modifier) {
}
@Composable
fun DownloadForOfflineIcon(iconSize: Dp) {
fun DownloadForOfflineIcon(iconSize: Dp, tint: Color = MaterialTheme.colorScheme.primary) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
null,
modifier = remember(iconSize) { Modifier.size(iconSize) },
tint = MaterialTheme.colorScheme.primary
tint = tint
)
}
@@ -121,11 +123,11 @@ fun LikedIcon(modifier: Modifier) {
}
@Composable
fun LikeIcon(iconSize: Dp, grayTint: Color) {
fun LikeIcon(iconSizeModifier: Modifier, grayTint: Color) {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = remember(iconSize) { Modifier.size(iconSize) },
modifier = iconSizeModifier,
tint = grayTint
)
}
@@ -160,11 +162,6 @@ fun ZappedIcon(modifier: Modifier) {
ZapIcon(modifier = modifier, BitcoinOrange)
}
@Composable
fun ZapIcon(iconSize: Dp, tint: Color = Color.Unspecified) {
ZapIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }, tint)
}
@Composable
fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
@@ -175,6 +172,36 @@ fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
)
}
@Composable
fun CashuIcon(modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.cashu),
"Cashu",
tint = Color.Unspecified,
modifier = modifier
)
}
@Composable
fun CopyIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
imageVector = Icons.Default.ContentCopy,
stringResource(id = R.string.copy_to_clipboard),
tint = tint,
modifier = modifier
)
}
@Composable
fun OpenInNewIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
Icon(
imageVector = Icons.Default.OpenInNew,
stringResource(id = R.string.copy_to_clipboard),
tint = tint,
modifier = modifier
)
}
@Composable
fun ExpandLessIcon(modifier: Modifier) {
Icon(
@@ -196,11 +223,11 @@ fun ExpandMoreIcon(modifier: Modifier) {
}
@Composable
fun CommentIcon(iconSize: Dp, tint: Color) {
fun CommentIcon(iconSizeModifier: Modifier, tint: Color) {
Icon(
painter = painterResource(R.drawable.ic_comment),
contentDescription = null,
modifier = remember { Modifier.size(iconSize) },
modifier = iconSizeModifier,
tint = tint
)
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -44,11 +43,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -75,7 +77,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@Composable
@@ -129,27 +130,21 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
}
Column(modifier = columnModifier) {
val (value, elapsed) = measureTimedValue {
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
}
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
Row(remember { Modifier.fillMaxWidth() }) {
Row(Modifier.fillMaxWidth()) {
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
val (value, elapsed) = measureTimedValue {
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
}
@@ -167,16 +162,15 @@ private fun Galeries(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
if (hasZapEvents) {
var zapEvents by remember(multiSetCard.zapEvents) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
mutableStateOf(
accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents)
)
}
LaunchedEffect(key1 = Unit) {
@@ -185,26 +179,17 @@ private fun Galeries(
}
}
val (value, elapsed) = measureTimedValue {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
if (hasBoostEvents) {
val (value, elapsed) = measureTimedValue {
RenderBoostGallery(boostEvents, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel)
}
if (hasLikeEvents) {
val (value, elapsed) = measureTimedValue {
likeEvents.forEach {
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
}
multiSetCard.likeEventsByType.forEach {
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
}
Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
}
}
@@ -301,6 +286,31 @@ fun RenderBoostGallery(
}
}
@Composable
fun RenderBoostGallery(
noteToGetBoostEvents: NoteState,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = NotificationIconModifierSmaller
) {
RepostedIcon(
modifier = remember {
Modifier
.size(Size19dp)
.align(Alignment.TopEnd)
}
)
}
AuthorGallery(noteToGetBoostEvents, nav, accountViewModel)
}
}
@Composable
fun MapZaps(
zaps: ImmutableList<CombinedZap>,
@@ -489,6 +499,22 @@ fun AuthorGallery(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGallery(
noteToGetBoostEvents: NoteState,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = StdStartPadding) {
FlowRow() {
noteToGetBoostEvents.note.boosts.forEach { note ->
BoxedAuthor(note, nav, accountViewModel)
}
}
}
}
@Composable
private fun BoxedAuthor(
note: Note,
@@ -527,7 +553,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
WatchUserMetadata(author) { baseUserPicture ->
// Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
RobohashFallbackAsyncImage(
robot = author.pubkeyHex,
model = baseUserPicture,
contentDescription = stringResource(id = R.string.profile_image),
@@ -34,12 +34,12 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize

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