Compare commits

..
325 Commits
Author SHA1 Message Date
Vitor Pamplona b079b3ef79 0.30.2 2023-03-29 18:15:44 -04:00
Vitor Pamplona a8c8ee1539 BugFix: Stick reply setup from Gigi. 2023-03-29 18:06:41 -04:00
Vitor Pamplona 8f14ccda86 BugFix Sticky notifications on account change 2023-03-29 18:06:19 -04:00
Vitor Pamplona a66f001033 Adds Image Upload on Profile Edit pages. 2023-03-29 17:22:21 -04:00
Vitor Pamplona ca79ceb1b9 Adjusts custom zap messages with double click. 2023-03-29 17:00:01 -04:00
Vitor Pamplona 0abac47ff2 More forgiving Wallet Connect interface (nsec or hexes are accepted, relay url is trimmed and cleaned up, if relay doesn't have a wss://, adds one) 2023-03-29 16:21:11 -04:00
Vitor PamplonaandGitHub 5038588d70 Merge pull request #319 from believethehype/main
Custom Messages for Zaps
2023-03-29 16:05:26 -04:00
Vitor Pamplona 19dab9ef4d Fixes hungarian xml 2023-03-29 15:57:03 -04:00
Vitor Pamplona 374fe85ce9 Merge remote-tracking branch 'origin/HEAD' 2023-03-29 15:52:42 -04:00
Vitor PamplonaandGitHub c1120aede3 Merge pull request #324 from believethehype/wallet-connect
Making it clearer in which format the relay should be entered for wallet-connect
2023-03-29 15:52:08 -04:00
Vitor PamplonaandGitHub e2f93ec17e Merge pull request #323 from ZsZolee/main
Adding additional Hungarian Translation
2023-03-29 15:51:42 -04:00
Vitor PamplonaandGitHub 78adb757da Merge pull request #322 from believethehype/profile_longnames
Fixed long usernames in profile
2023-03-29 15:51:21 -04:00
Believethehype 3cd6bab7e9 Update UpdateZapAmountDialog.kt 2023-03-29 18:26:44 +02:00
believethehypeandGitHub 1918fe24fc Merge branch 'vitorpamplona:main' into main 2023-03-29 18:24:10 +02:00
ZoltanandGitHub 5262368b61 Merge pull request #1 from ZsZolee/HUN-add
Adding additional Hungarian Translation
2023-03-29 10:58:42 +02:00
ZoltanandGitHub d6a8b70852 Adding additional Hungarian Translation 2023-03-29 10:57:23 +02:00
Believethehype d61844436d Update ProfileScreen.kt 2023-03-29 10:52:31 +02:00
Vitor Pamplona e41da35a02 Adds image url when previews can't be loaded. 2023-03-28 18:13:00 -04:00
Vitor Pamplona b865d15adf Increases speed of the bottom navigation by avoiding recreating ViewModels 2023-03-28 17:11:38 -04:00
Believethehype 42e0cda2e2 don't reset dialog on close 2023-03-28 22:24:00 +02:00
Believethehype ad70cfb8d7 updated ZapCustomDialog 2023-03-28 22:22:28 +02:00
believethehypeandGitHub caaf554371 Merge branch 'vitorpamplona:main' into main 2023-03-28 21:15:22 +02:00
Vitor Pamplona 73cabc1b25 Fixing the position of the text in short messages with expandable content. 2023-03-28 11:34:20 -04:00
Vitor Pamplona 9590eecacd Turning Relay icon FlowRow into a LazyVerticalGrid to see if improves rendering speed. 2023-03-28 10:55:30 -04:00
Vitor Pamplona 8eb3a47844 Removing unnecessary logs from the Relay class 2023-03-28 09:01:47 -04:00
Vitor Pamplona 9ae860a66a Moving byte count to count all messages from relays, including errors. 2023-03-28 09:01:26 -04:00
Vitor Pamplona ceab807f98 BugFix for cutting links in half when they are the last word in a post. 2023-03-28 08:59:08 -04:00
Vitor Pamplona 6b61132f15 Makes the Scan QR Button available in smaller screens. 2023-03-28 08:47:39 -04:00
Vitor Pamplona cdcf6f9b0a Adds PoW Display to notes 2023-03-28 08:46:07 -04:00
Vitor Pamplona e1f2313054 v0.30.1 2023-03-27 10:10:04 -04:00
Vitor Pamplona 23a99f3a8c Merge remote-tracking branch 'origin/HEAD' 2023-03-27 09:59:04 -04:00
Vitor Pamplona 53393a350c Fixes Possible crash when typing invalid keys 2023-03-27 09:58:45 -04:00
Vitor Pamplona 56e83d3fe3 Adds special private key for Wallet Connect 2023-03-27 09:54:01 -04:00
believethehypeandGitHub 8aa545f920 Merge branch 'vitorpamplona:main' into main 2023-03-26 23:58:40 +02:00
Believethehype 5f459ac924 Custom Messages for Zaps
- Includes adding a custom message to sats in Backend Code

- Added a simple window on double tap on the bolt symbol for custom amount + message

- ! The popup might need some beautification

- ! Messages are not shown yet in Amethyst

- ! Window could provide options for non-zaps and private zaps in the future
2023-03-26 23:57:25 +02:00
Vitor PamplonaandGitHub 5ce35363d9 Merge pull request #318 from believethehype/main
Show "follow back" on a user's profile when this user already is a follower
2023-03-26 17:24:44 -04:00
Believethehype 913fa4ba5b Show follow back on profile when profile already follows user 2023-03-26 21:24:13 +02:00
Vitor Pamplona 7a50f97773 0.30.0 2023-03-26 11:55:23 -04:00
Vitor Pamplona 0bd5ad9496 removing align to end of the Auto-translated message. 2023-03-26 11:54:56 -04:00
Vitor Pamplona 2c4be592ad Correctly counts pruned contact lists. 2023-03-26 11:42:30 -04:00
Vitor PamplonaandGitHub 2183639719 Merge pull request #316 from miseelu/main
Updated Chinese (Traditional) Translation
2023-03-26 11:28:48 -04:00
Vitor Pamplona 0602150cd8 Only runs scroll to top once. 2023-03-26 11:10:26 -04:00
Vitor Pamplona c7327c8b91 Slimmer look to the Zap button 2023-03-26 10:44:16 -04:00
Vitor Pamplona e5e47795c1 It should not clear the navigation stack when entering threads. 2023-03-26 10:34:28 -04:00
Vitor Pamplona 0980208cce Merge remote-tracking branch 'origin/better_use_of_eose_at_memory_cost' 2023-03-26 10:11:18 -04:00
Vitor Pamplona 93d6d2ed3e - Preloads URL Previews to avoid jjittering when loading previews.
- Remembers video playback functions to avoid jittering
- Refactors Invalidate Calls into an object.
2023-03-26 10:02:38 -04:00
Vitor Pamplona 4a77d8b134 Videos now go full screen. 2023-03-25 16:51:37 -04:00
Vitor Pamplona 7e89822cbf BugFix for nostr: in content. 2023-03-25 16:16:41 -04:00
Milu Lu 8231e03a94 Updated Chinese(Simplified) Translation 2023-03-26 01:53:10 +08:00
Milu Lu 26e47d7a10 Updated Chinese (Traditional) Translation 2023-03-26 01:52:31 +08:00
Milu Lu d02ee4dc53 Merge remote-tracking branch 'origin/main' 2023-03-26 01:48:17 +08:00
Vitor Pamplona 1f4f5e3094 Reduces the amount of download from relays, but at increased memory costs. 2023-03-25 12:08:51 -04:00
Vitor PamplonaandGitHub fb14c4e872 Merge pull request #315 from miseelu/main
Do not show notes with the creation time exceeding the current time
2023-03-25 12:07:47 -04:00
Milu Lu ec503f4963 Update translation menu right alignment 2023-03-25 20:13:33 +08:00
Milu Lu a0869349bd Updated Chinese (Traditional) Translation 2023-03-25 19:52:38 +08:00
Milu Lu 0f71c01632 Updated Chinese(Simplified) Translation 2023-03-25 19:51:51 +08:00
Milu Lu c2723fe9dd Updated Chinese(Simplified) Translation 2023-03-25 19:44:31 +08:00
Milu Lu 3936edb3b7 Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating. 2023-03-25 18:04:46 +08:00
Vitor Pamplona 5cc0bf54be v0.29.2 2023-03-24 17:25:16 -04:00
Vitor Pamplona 27e053dcd2 Merge remote-tracking branch 'origin/HEAD' 2023-03-24 17:04:50 -04:00
Vitor PamplonaandGitHub aaa0678c18 Merge pull request #314 from believethehype/main
#SkullofSatoshi
2023-03-24 17:04:28 -04:00
Vitor Pamplona cd03ed354f Adjusts zap animation size 2023-03-24 17:03:58 -04:00
Believethehype 10f3551c4f Update HashtagIcon.kt 2023-03-24 21:51:54 +01:00
Believethehype 6bb1f260ea Update HashtagIcon.kt 2023-03-24 21:49:16 +01:00
Believethehype d889c675a0 Update HashtagIcon.kt 2023-03-24 21:47:35 +01:00
Believethehype 757f6982bd Merge branch 'main' of https://github.com/believethehype/amethyst 2023-03-24 21:35:11 +01:00
Believethehype d88581ac88 #SkullOfSatoshi 2023-03-24 21:34:29 +01:00
Vitor Pamplona 2b60c24c86 0.29.1 2023-03-24 13:27:29 -04:00
Vitor Pamplona 99f84e891e Merge remote-tracking branch 'origin/HEAD' 2023-03-24 13:19:51 -04:00
Vitor PamplonaandGitHub efa0672241 Merge pull request #312 from greenart7c3/main
add missing pt-br translations
2023-03-24 13:17:35 -04:00
Vitor Pamplona 987a3026ac Support for Translateable RichText on About Me section of the Profile 2023-03-24 13:15:23 -04:00
Vitor Pamplona bbf33df04c Adds an Image Carousel when there are more than 1 image in the post. 2023-03-24 12:33:16 -04:00
Vitor Pamplona 8b81e2e279 Requesting reposts from UserProfile 2023-03-24 10:56:41 -04:00
Vitor Pamplona 3d8870c342 Simplifying the filter for home 2023-03-24 10:56:23 -04:00
Vitor Pamplona 1a923cefaf Removes the tag from the autotranslation index to avoid translating the name itself. 2023-03-24 10:03:56 -04:00
Vitor Pamplona eed62c36b9 Creates a Loading screen to get events before redirecting them to the appropriate screen (DMs, Channels, Threads) 2023-03-24 09:58:28 -04:00
greenart7c3 839a691a67 add missing pt-br translations 2023-03-24 08:13:30 -03:00
Vitor Pamplona e910260375 Merge remote-tracking branch 'origin/HEAD' 2023-03-23 17:58:16 -04:00
Vitor PamplonaandGitHub 7c9510a6c9 Merge pull request #310 from lgleasain/main
adding in support for clicking on lightning withdrawal links intenting out to a wallet
2023-03-23 17:53:24 -04:00
Vitor Pamplona 3030d474e2 Migrates per-relay stats from event counter to bytes transmitted. 2023-03-23 17:52:26 -04:00
Vitor Pamplona d9c0b8cd20 Fixes mark for users that pledged sats to a bounty 2023-03-23 17:20:33 -04:00
Lance Gleason 51fbf1b1a5 adding in support for clicking on lightning withdrawal links and having it go to a wallet 2023-03-23 14:52:15 -06:00
Vitor Pamplona ba33b5e1c6 removes prefixes of unsecured websocket addresses 2023-03-23 16:30:52 -04:00
Vitor Pamplona 81d9037807 Simplifying Reply unwrap for messages made by the logged in Person 2023-03-23 15:47:20 -04:00
Vitor Pamplona 79c894dcfd Moving Markdown default styling to a singleton 2023-03-23 15:21:44 -04:00
Vitor Pamplona e4c98cf39f Fixing spaces after url 2023-03-23 15:21:25 -04:00
Vitor Pamplona ed1e080fbe Support for r tags 2023-03-23 15:20:35 -04:00
Vitor Pamplona afef10189b v0.29.0 2023-03-23 12:32:14 -04:00
Vitor Pamplona 1a66319a6d Support to pledge additional bounty amounts. 2023-03-23 12:24:46 -04:00
Vitor Pamplona 7a1470ef7b BugFix: Don't add e tags with addresses. 2023-03-23 12:24:11 -04:00
Vitor Pamplona be09577db1 Adds extra space before uncited hashtags. 2023-03-23 11:07:29 -04:00
Vitor Pamplona f91a0d4cd0 Fixes hashtag links 2023-03-23 11:07:02 -04:00
Vitor Pamplona 4589e6f329 Adding Bounty information to Note 2023-03-23 10:49:01 -04:00
Vitor Pamplona 7375c9e9e3 Adds border for Markdown texts 2023-03-23 10:46:59 -04:00
Vitor Pamplona ccd71a8023 Adds default banner image to LongFormPosts if none is found 2023-03-23 09:04:44 -04:00
Vitor Pamplona 3a76d176c5 Adds user's picture and background to LongForm posts without a default image 2023-03-23 08:58:18 -04:00
Vitor Pamplona 6626be8e1c Displays hashtags that are not in the text at the bottom of each note 2023-03-23 08:57:28 -04:00
Vitor Pamplona dd9ea5f27e Support for video uploads. 2023-03-22 16:50:18 -04:00
Vitor Pamplona cb20e14822 Sending Addressable notes to Habla.news: Fixes: https://github.com/vitorpamplona/amethyst/issues/295 2023-03-22 15:47:01 -04:00
Vitor Pamplona b9aa099a1e Adding space on inner notes 2023-03-22 15:03:14 -04:00
Vitor Pamplona 1e8bc10ad1 Showing progress bars when Zaps are requested 2023-03-22 14:24:33 -04:00
Vitor Pamplona ba40b1815f Checks if the requested amount to matches the received LN Invoice's amount 2023-03-22 14:23:42 -04:00
Vitor Pamplona cf2c56e088 Adds instructions of use for the wallet connect 2023-03-22 11:56:12 -04:00
Vitor Pamplona 896758121b Blocks translations of @npub/@note tags in the text. 2023-03-22 11:24:58 -04:00
Vitor Pamplona f0f9726ede Support for Wallet Connect Api 2023-03-22 09:45:21 -04:00
Vitor Pamplona ddb3990c11 Fully hiding chat messages from hidden users. 2023-03-21 14:15:58 -04:00
Vitor Pamplona 8a72e3b7ac Reducing the amount of preloaded messages in chat. 2023-03-21 14:15:26 -04:00
Vitor Pamplona aae1a65cba Refactoring: notNull filter is not needed. 2023-03-21 14:13:32 -04:00
Vitor PamplonaandGitHub fb017c3903 Merge pull request #299 from believethehype/main
Minor Cosmetics for HashTagIcons
2023-03-21 08:29:23 -04:00
Believethehype 0c1b9195d7 higher resolution icons, fixed descriptions, positioning 2023-03-21 10:27:38 +01:00
Believethehype 1fe58810a0 2 additional popular community hashtagicons
plebchain / coffeechain
2023-03-21 08:46:19 +01:00
Believethehype 3cde9bcdd5 Update HashtagIcon.kt 2023-03-21 08:21:56 +01:00
Believethehype 0c3b73f4d3 Minor Cosmetics for HashTagIcons
- Made Icons a little bit larger again, as it was before last (scaling fix) update.

- Added some more popular variations of currently supported hts.
2023-03-21 08:19:18 +01:00
Vitor Pamplona 25883d00ce v0.28.1 2023-03-20 20:57:12 -04:00
Vitor PamplonaandGitHub d04bbfc640 Merge pull request #297 from believethehype/main
Better scaling for HashtagIcons for different System font sizes
2023-03-20 20:50:11 -04:00
Vitor Pamplona 7da837f49e Always hide the note if the user has selected to hide user. 2023-03-20 20:46:25 -04:00
Vitor Pamplona 9709ffe388 Removes the possibility of duplicated event in Bookmarks 2023-03-20 20:45:36 -04:00
Believethehype f7cc656288 Update HashtagIcon.kt 2023-03-21 01:08:15 +01:00
Believethehype 3ba979e513 Update HashtagIcon.kt 2023-03-21 00:55:27 +01:00
Vitor Pamplona eadb4a108c Fixing EO Translations 2023-03-20 18:50:25 -04:00
Vitor Pamplona 8b44816520 v0.28.0 2023-03-20 18:26:03 -04:00
Vitor Pamplona 2fb673acf0 Bookmarks 2023-03-20 18:16:07 -04:00
Believethehype 8401f09475 Update HashtagIcon.kt 2023-03-20 20:39:32 +01:00
Believethehype c4400ded28 Better scaling for HashtagIcons for different System font sizes 2023-03-20 20:32:56 +01:00
Vitor Pamplona fc37789727 Adds a filter for messages by the author in public chats from all relays. 2023-03-20 12:10:09 -04:00
Vitor Pamplona abb27ac811 Refactors ChatroomList datasource startup to be more explicit 2023-03-20 09:44:11 -04:00
Vitor PamplonaandGitHub fbf5fb4aab Merge pull request #290 from believethehype/main
simplified HashTagIcon code, new suggestions for #lightning and #zap
2023-03-20 08:59:41 -04:00
Vitor PamplonaandGitHub 3887a6ae44 Merge pull request #291 from Radiokot/fix/actualize_ru_uk_23_03_18
Actualize Russian and Ukrainian translations
2023-03-20 08:45:49 -04:00
Vitor PamplonaandGitHub cfb342cd74 Merge pull request #294 from chiajlingvoj/main
create strings.xml for Eo translation
2023-03-20 08:45:01 -04:00
Vitor Pamplona 2d693145d9 Follows UserAgent version syntax: User-Agent: <product> / <product-version> <comment> 2023-03-20 08:41:03 -04:00
Vitor PamplonaandGitHub d62a2480f4 Merge pull request #296 from hsoc/useragent-ver
User-Agent fix
2023-03-20 08:36:13 -04:00
John Economou b30fb1d9c7 User-Agent fix
include client version in user-agent
2023-03-20 13:43:25 +02:00
chiajlingvojandGitHub fb7c7a723b create strings.xml for Eo translation 2023-03-19 09:50:11 -04:00
Oleg Koretsky fd8517e047 Actualize Russian and Ukrainian translations 2023-03-18 11:25:03 +02:00
Believethehype ab0361bf27 Update zap.xml 2023-03-18 03:39:15 +01:00
Believethehype eb40540f0d Update zap.xml 2023-03-18 03:32:26 +01:00
Believethehype c1f8116a5a simplified HashTagIcon code, new suggestions for #lightning and #zap 2023-03-18 03:12:24 +01:00
Vitor Pamplona 40aadf77f7 Fixing the correct tagging with tags are placed together with additional characters. 2023-03-17 19:25:51 -04:00
Vitor Pamplona adafb6b183 No need for spaces after npubs anymore 2023-03-17 19:25:11 -04:00
Vitor Pamplona d38a7169b8 Fixing spaces after urls 2023-03-17 19:09:43 -04:00
Vitor Pamplona 53705f214a Adding relay icon from https://github.com/vitorpamplona/amethyst/issues/188 2023-03-17 19:09:23 -04:00
Vitor PamplonaandGitHub 155bb80118 Merge pull request #288 from believethehype/main
Fix for hashtag icon resolutions
2023-03-17 18:49:02 -04:00
Believethehype de77f2e18b typo 2023-03-17 23:39:23 +01:00
Vitor Pamplona b2f05b4247 Fixing mutable object to recalculate zap amount. 2023-03-17 18:38:52 -04:00
Believethehype 88292aa179 updated icons 2023-03-17 23:34:59 +01:00
Vitor Pamplona 79ba910f52 Not showing reply information in inner short posts. 2023-03-17 18:01:57 -04:00
Vitor Pamplona 30402b26e8 Removing double space in " and " 2023-03-17 18:01:32 -04:00
Vitor Pamplona cb6401c7ab Don't preview urls without schema. Show inline link instead. 2023-03-17 18:01:08 -04:00
believethehypeandGitHub 435e99e81a Merge branch 'vitorpamplona:main' into main 2023-03-17 22:59:05 +01:00
Believethehype 4b7167d5ef additional zap hts 2023-03-17 22:58:17 +01:00
Believethehype 9887ec2d38 added additional resolutions for hashtagicons, added #zap #lightning #amethyst 2023-03-17 22:53:39 +01:00
Vitor Pamplona edb61e2423 No need for NIP05 in Quoted posts 2023-03-17 17:08:55 -04:00
Vitor Pamplona 8b134892ce Marks PlayStore as default for now 2023-03-17 16:54:57 -04:00
Vitor Pamplona 21cb82a05d v0.027.2 2023-03-17 14:40:35 -04:00
Vitor Pamplona 412c5c557a Adding the suffix after the hashtag icon 2023-03-17 14:06:00 -04:00
Vitor Pamplona 81e31319c7 Making tags case insensitive 2023-03-17 10:22:58 -04:00
Vitor Pamplona 97ec28d2c8 Additional suffix support for hashtags. 2023-03-17 10:09:08 -04:00
Vitor Pamplona c6906c42fd Mocking Android.Log for test classes 2023-03-17 09:47:47 -04:00
Vitor Pamplona 7921db3ba9 Scaling hashtag images 2023-03-17 09:43:09 -04:00
Vitor Pamplona b12413cbff Removing comments from NIP19 Parser 2023-03-17 09:42:54 -04:00
Vitor Pamplona 07cd1738f1 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:30:33 -04:00
Vitor PamplonaandGitHub b8aa7e64b3 Merge pull request #284 from believethehype/main
Added Special Bitcoin Hashtag with Icon
2023-03-17 09:30:06 -04:00
Vitor Pamplona 055ed62cb7 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:29:26 -04:00
Vitor Pamplona 578c70d272 Correctly parsing NIP19s in Text 2023-03-17 09:28:48 -04:00
Vitor PamplonaandGitHub d43f0435d0 Merge pull request #287 from ZsZolee/main
Minor changes
2023-03-17 09:23:12 -04:00
Believethehype 52978fa237 Hashtags in front of a sentence ending should nod work, too. 2023-03-17 13:17:19 +01:00
Believethehype f266a9e964 Special Hashtag for #Nostr 2023-03-17 12:38:47 +01:00
ZoltanandGitHub 01cb27e6f5 Minor changes
Minor changes in the Hungarian translation
2023-03-17 10:12:50 +01:00
Believethehype 38730ea534 fixed some linting issues 2023-03-17 08:03:47 +01:00
believethehypeandGitHub a955f55fc6 Merge branch 'vitorpamplona:main' into main 2023-03-17 07:24:29 +01:00
Believethehype a42cdaaf91 added special Bitcoin Hashtag with Icon
(S/O to tata)
2023-03-16 23:44:25 +01:00
Vitor Pamplona 0633707ec6 Disabling online search after leaving the Search screen 2023-03-16 16:33:17 -04:00
Vitor Pamplona 8768daa06f Fixing Background color of the badge presentation 2023-03-16 16:32:42 -04:00
Vitor Pamplona fb0a7b0ae4 Fixing missing messages on chat 2023-03-16 16:31:42 -04:00
Vitor Pamplona f8b6b8fb45 v0.27.1 2023-03-16 14:24:58 -04:00
Vitor Pamplona 888b6daa2a Support for Following Hashtags 2023-03-16 14:20:30 -04:00
Vitor Pamplona 811c373e4c v0.27.0 2023-03-15 17:07:06 -04:00
Vitor Pamplona 1812ec296e Hashtags 2023-03-15 17:02:49 -04:00
Vitor Pamplona 59cdd81aec Shows LongForm Texts on Global 2023-03-15 17:02:13 -04:00
Vitor Pamplona 9297afc02a Deleting contact lists from pausing the app 2023-03-15 15:13:23 -04:00
Vitor Pamplona a8d8be3307 Removing unused mentions in LocalCache 2023-03-15 14:35:10 -04:00
Vitor Pamplona 53ec6c817c Fixes the deletion of private messages and messages in tags 2023-03-15 14:28:03 -04:00
Vitor Pamplona b7c4ec8358 Shows url host in the link preview 2023-03-15 14:06:26 -04:00
Vitor Pamplona 3426ff307c Makes sure onClick and onLongClick are null when their counterparts are as well. 2023-03-15 13:58:09 -04:00
Vitor Pamplona cddb713b2f Adds the following icon to the chat header 2023-03-15 13:52:26 -04:00
Vitor Pamplona 1871fe6177 Locally cache following Users in the Home & Conversations filter 2023-03-15 13:40:26 -04:00
Vitor Pamplona 83a8558f23 Caching followingChannels and followingUsers to speed Global filter up 2023-03-15 13:39:47 -04:00
Vitor Pamplona dbc155454f Checks for hex keys in the tag list before showing follows 2023-03-15 13:39:06 -04:00
Vitor Pamplona 80e251acfb Avoids creating User for all awardees of a badge. 2023-03-15 13:22:11 -04:00
Vitor Pamplona a597bd7882 Checks the mentions for a valid Hex before creating users in the cache. 2023-03-15 13:14:10 -04:00
Vitor Pamplona a53bb53c12 Avoids breaking urls and invoices when cutting an expandable content 2023-03-15 13:13:41 -04:00
Vitor Pamplona 4fa7aa54a8 Refactoring Note's hasReports function 2023-03-15 13:12:55 -04:00
Vitor Pamplona a9490e1299 Refactoring calls to check if a user is the signed in user. 2023-03-15 13:12:21 -04:00
Vitor Pamplona 2bf36f1cfe Implements a faster AuthorGallery for notifications 2023-03-15 11:39:30 -04:00
Vitor Pamplona c781d5eff7 Forces image size in profile pictures to reduce calculations before resizing the downloaded image. 2023-03-15 11:21:53 -04:00
Vitor Pamplona e8d84795d5 Reduces badge size in Profiles 2023-03-15 11:21:00 -04:00
Vitor Pamplona b351a781a9 Removes time measuring functions from RoboHash 2023-03-15 11:20:36 -04:00
Vitor Pamplona 97d9e96e2b Uses cached functions for isFollowing calculations 2023-03-15 11:20:09 -04:00
Vitor Pamplona 625b8c3bce Makes the reporting Menu less starkey in dark theme 2023-03-15 09:11:55 -04:00
Vitor PamplonaandGitHub edd4a4cc2c Merge pull request #275 from maxmoney21m/feature/216-merge-reports
Add separate screen for report & block
2023-03-15 08:09:07 -04:00
Vitor PamplonaandGitHub b305ccad1d Merge pull request #280 from maxmoney21m/bugfix/gitignore
Remove ignored file
2023-03-15 08:08:39 -04:00
Vitor PamplonaandGitHub 14ca6f1d08 Merge pull request #281 from maxmoney21m/bugfix/string-parameters
Add parameters for strings with variables
2023-03-15 08:08:05 -04:00
maxmoney21m ad4e6d6596 Add Block button and dialog 2023-03-15 13:40:59 +08:00
maxmoney21m e94d2b5340 Update gitignore 2023-03-15 11:29:05 +08:00
maxmoney21m 0241bf0913 Add report & block screen 2023-03-15 11:29:04 +08:00
maxmoney21m 36885eb6dc Add parameters for strings with variables 2023-03-15 11:26:51 +08:00
maxmoney21m c1917855d0 Remove ignored file 2023-03-15 10:42:53 +08:00
Vitor Pamplona 0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona 063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor PamplonaandGitHub 4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona 2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona 8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
Vitor Pamplona f8bb4a3168 v0.26.0 2023-03-14 18:30:48 -04:00
Vitor Pamplona 1b8923b6d7 Speeding up Notification and Global feed filters 2023-03-14 18:30:26 -04:00
Vitor PamplonaandGitHub 675b59c0d2 Merge pull request #233 from vitorpamplona/less_memory_test_branch
Restructuring User/Note
2023-03-14 17:56:38 -04:00
Vitor Pamplona a71078f90f Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-14 17:46:29 -04:00
Vitor Pamplona d4be271060 Fixing typos in the path 2023-03-14 16:27:28 -04:00
Vitor Pamplona 2d59baf898 Uses unique IDs for each job in Actions 2023-03-14 16:14:17 -04:00
Vitor Pamplona 18f651b67f v0.25.3 Testing Build Scripts 2023-03-14 16:03:44 -04:00
Vitor Pamplona f65e09f388 Adding scripts to release all variants of the app 2023-03-14 15:55:08 -04:00
Vitor PamplonaandGitHub a4bb01ef7d Merge pull request #276 from maxmoney21m/feature/54-fdroid-flavor
Make release channel flavors, remove mlkit from fdroid channel
2023-03-14 15:26:11 -04:00
maxmoney21m e9c0d350e1 Make release channel flavors, remove mlkit from fdroid channel 2023-03-15 02:34:50 +08:00
Vitor Pamplona faa29675db 0.25.2 2023-03-14 13:01:13 -04:00
Vitor PamplonaandGitHub 96990c44a6 Merge pull request #274 from vitorpamplona/pr/273
Pr/273
2023-03-14 12:19:46 -04:00
Vitor PamplonaandGitHub 424fe5fd3c Merge pull request #273 from believethehype/main
Image upload button in Chats
2023-03-14 12:19:17 -04:00
Vitor Pamplona 06d3784261 Moving Upload Image buttons to be inside the TextEdit 2023-03-14 12:02:25 -04:00
Believethehype 9e3c08ade8 Image upload button in Chats 2023-03-14 15:27:58 +01:00
Vitor Pamplona 3b7fc7173d This call already happens inside Login 2023-03-14 09:46:39 -04:00
Vitor Pamplona 105c7b9fe8 Avoids adding an empty string when the list is empty because it will parse it back as a list with 1 element with an empty npub. 2023-03-14 09:46:09 -04:00
Vitor PamplonaandGitHub c0c45d1385 Merge pull request #270 from Bardesss/patch-2
Update NL strings.xml
2023-03-14 08:48:17 -04:00
Vitor PamplonaandGitHub 9b2ce25668 Merge pull request #271 from maxmoney21m/bugfix/account-switch-tabs
Bugfix: Reset feedfilter accounts when switching accounts
2023-03-14 08:48:04 -04:00
Vitor PamplonaandGitHub 7e2b28503e Merge pull request #269 from maxmoney21m/bugfix/apptopbar-bgcolor
Fix AppTopBar background color
2023-03-14 08:47:37 -04:00
maxmoney21m d798db7cf1 Reset FeedFilter account when account changes 2023-03-14 18:23:04 +08:00
BardesssandGitHub aa2f718589 Update NL strings.xml
Added missing strings an fixed some mistakes.
2023-03-14 09:26:35 +00:00
maxmoney21m f3a4a7e143 Fix AppTopBar background color 2023-03-14 13:53:38 +08:00
Vitor Pamplona 732abb70f3 v0.25.1 2023-03-13 22:01:35 -04:00
Vitor Pamplona 8c98a8849c Adding the same button style for before and after granted permissions 2023-03-13 17:57:51 -04:00
Vitor Pamplona 5ce6d06920 Reverting QuickAction to the DropDown Menu until we get reporting functions implemented. 2023-03-13 17:46:09 -04:00
Vitor Pamplona bb6b4c208d Moves SVG generation to fetcher (IO) and avoids the use of TrimIndent, which is too slow (2-10ms each) for this context. 2023-03-13 17:38:30 -04:00
Vitor Pamplona ea63522745 Updating dependencies 2023-03-13 17:36:47 -04:00
Vitor Pamplona 698c9b00cb v0.25.0 2023-03-13 15:37:55 -04:00
Vitor Pamplona 1c740d4d7a Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
2023-03-13 14:51:44 -04:00
Vitor PamplonaandGitHub 0491c25e9f Merge pull request #268 from maxmoney21m/fix-all-warnings
Fixed (almost) all test warnings
2023-03-13 14:47:17 -04:00
Vitor Pamplona 75ed380449 Fixing merge issue 2023-03-13 14:32:21 -04:00
maxmoney21m 2820197905 Fixed (almost) all test warnings 2023-03-14 02:32:00 +08:00
Vitor Pamplona 01818ff458 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt
2023-03-13 14:09:17 -04:00
Vitor Pamplona d19592aef3 Activates .mov video previews 2023-03-13 13:49:06 -04:00
Vitor PamplonaandGitHub 9c4f9fce37 Merge pull request #267 from matata2140/patch-1
Update strings.xml
2023-03-13 13:37:27 -04:00
Vitor PamplonaandGitHub c06ed8d338 Merge pull request #258 from maxmoney21m/215-robohash-identicons
Generate robohash using SVGs
2023-03-13 13:36:05 -04:00
maxmoney21m 0f42c2707e Rebase on main / fix merge conflicts 2023-03-14 01:08:15 +08:00
maxmoney21m 5c518501ab Edit background shape, use painter placeholder/fallback
Use logo-shaped background

Use painter as placeholder and fallback
2023-03-14 01:07:48 +08:00
maxmoney21m 887d963c5b Remove original robohash dependency 2023-03-14 01:07:43 +08:00
maxmoney21m 0eb21a6650 Replace remaining avatars with robot fallback images 2023-03-14 01:07:42 +08:00
maxmoney21m 9f6867b6ab Switch PFPs to use the robohash proxy 2023-03-14 01:07:31 +08:00
maxmoney21m 6403bd21f8 Add async image proxy for robohash images 2023-03-14 00:53:22 +08:00
maxmoney21m b3f2a03295 Add RoboHash SVG generator 2023-03-14 00:53:21 +08:00
Vitor PamplonaandGitHub 27b2cda14f Merge pull request #249 from maxmoney21m/feature/zxing-qr-scanner
Replace mlkit with zxing QR scanner
2023-03-13 11:27:45 -04:00
Vitor PamplonaandGitHub f61ec0b5ab Merge pull request #266 from maxmoney21m/bugfix/okhttp-ua-header
Add user-agent header to OkHttpClient requests
2023-03-13 11:09:51 -04:00
maxmoney21m 53d692ec7f Add user-agent header, fixes #259 2023-03-13 23:02:53 +08:00
Vitor PamplonaandGitHub 12876cdacd Merge pull request #245 from maxmoney21m/feature/keep-feed-position
Keep feed position while navigating different screens
2023-03-13 10:55:54 -04:00
Vitor PamplonaandGitHub 63a14b7409 Merge pull request #265 from maxmoney21m/bugfix/gitignore
Update gitignore
2023-03-13 10:53:33 -04:00
Vitor Pamplona cd5e0187e8 Refining the selection size when clicking in an account. 2023-03-13 10:20:48 -04:00
maxmoney21m 02d74c611a Remove .idea files 2023-03-13 21:42:09 +08:00
maxmoney21m a367bf5982 Update gitignore 2023-03-13 21:41:02 +08:00
maxmoney21m d8c2e623d1 Hoist HomeScreen state 2023-03-13 20:28:30 +08:00
maxmoney21m 70434eb244 Remove Route buildScreen member 2023-03-13 20:17:35 +08:00
maxmoney21m 85d722f96d Hoist globalfeed state out of Screen component 2023-03-13 19:03:15 +08:00
maxmoney21m e4cec20d1a Make forceRefresh non-null 2023-03-13 13:07:01 +08:00
maxmoney21m bb7dcbdb78 Resolve merge conflicts on main 2023-03-13 11:35:08 +08:00
matataandGitHub 260ed29309 Update strings.xml
I wasn't sure about translating 'channel' so I left them as is, but:
If 'channel' refers to 'dm group-chat' then go on and replace all 'kanaal' with 'groep'
2023-03-13 00:12:00 +01:00
Vitor PamplonaandGitHub 9fc1fa9f6c Merge pull request #263 from maxmoney21m/feature/multiple-accounts
Multiple account management
2023-03-12 18:09:50 -04:00
Vitor PamplonaandGitHub 199f9fbe69 Merge pull request #264 from Chemaclass/refactor-LocalCache-isValidHexNpub
Refactor LocalCache.isValidHexNpub()
2023-03-12 17:57:16 -04:00
Chemaclass 2fea7dac62 Refactor LocalCache.isValidHexNpub() 2023-03-12 18:42:17 +01:00
maxmoney21m 90147ce557 Allow tapping nav icon to refresh & scroll to top 2023-03-12 22:40:26 +08:00
maxmoney21m 4fa584ca1a Remove swiperefresh dependency 2023-03-12 22:40:26 +08:00
maxmoney21m 7630e07dc9 Replace deprecated SwipeRefresh with PullRefresh 2023-03-12 22:40:26 +08:00
maxmoney21m 5da70a0f95 Persist lazy list state on Home, Global and Chatrooms 2023-03-12 22:39:02 +08:00
maxmoney21m be19d0a9cf Add persistent lazy list state composable 2023-03-12 22:39:02 +08:00
maxmoney21m 20431a87ea Use List instead of Set to keep account order 2023-03-12 22:37:23 +08:00
maxmoney21m cc36dcffe0 Extract strings 2023-03-12 22:37:22 +08:00
maxmoney21m 32b50418de Indicate whether account has pubkey or privkey 2023-03-12 22:37:21 +08:00
maxmoney21m b1cb7d599f Make account switcher scrollable 2023-03-12 22:37:21 +08:00
maxmoney21m f34d79106d Fix account switching 2023-03-12 22:37:20 +08:00
maxmoney21m 269197a59d Migration from old to new preferences 2023-03-12 22:37:20 +08:00
maxmoney21m 992796a7bf Delete user-specific preference xml file on logout 2023-03-12 22:37:19 +08:00
maxmoney21m e472a494a1 Re-use existing LoginPage component in account switcher 2023-03-12 22:37:18 +08:00
maxmoney21m b40bde10a0 Add account, switch account, and logout flows 2023-03-12 22:37:17 +08:00
maxmoney21m 9561261bf4 Add note 2023-03-12 22:37:17 +08:00
maxmoney21m 3a2403b344 Use multiple preference files for different accounts 2023-03-12 22:37:16 +08:00
maxmoney21m b7f8241a08 Add account switch bottom sheet and update LocalPrefs 2023-03-12 22:37:15 +08:00
Vitor PamplonaandGitHub 907aa7b734 Merge pull request #256 from maxmoney21m/bugfix/note-quick-actions
Add quick action menu in chats and threads
2023-03-12 10:26:31 -04:00
Vitor PamplonaandGitHub 74336d72c0 Merge pull request #251 from h3y6e/patch-1
Correct identity claim for mastodon
2023-03-12 10:25:38 -04:00
Vitor PamplonaandGitHub 0b9b288146 Merge pull request #255 from maxmoney21m/bugfix/254
Fix string
2023-03-12 10:24:10 -04:00
Vitor PamplonaandGitHub b7deaaa1a6 Merge pull request #257 from akiomik/add-string-resources
Add string resources to strings.xml
2023-03-12 10:23:48 -04:00
Vitor PamplonaandGitHub 9dc9d7d947 Merge pull request #260 from Bardesss/patch-1
Fixed NL translation
2023-03-12 10:22:57 -04:00
Vitor PamplonaandGitHub a69a5b1a1e Merge pull request #261 from Chemaclass/refactor-LocalCache
Refactor LocalCache
2023-03-12 10:22:41 -04:00
Vitor PamplonaandGitHub 5842a84309 Merge pull request #262 from kappaseijin/fixed_var_name_for_NIP-05
Fixed a variable name for NIP-05
2023-03-12 10:19:23 -04:00
kappaseijin 332ddc15f7 Fixed a variable name for NIP-05 2023-03-12 14:43:33 +09:00
Chemaclass 30f4ff9d6f Remove indentation level LocalCache.consume(ChannelCreateEvent) 2023-03-11 20:43:46 +00:00
Chemaclass d166f22c58 Extract checkIfValidHex() 2023-03-11 20:34:44 +00:00
BardesssandGitHub bee361cea7 Update strings.xml
Fixed threads
2023-03-11 13:26:59 +00:00
Akiomi Kamakura 31e724662d Add string resources to strings.xml 2023-03-11 13:02:35 +09:00
maxmoney21m 6109131ef5 Add quick action menu in chats and threads 2023-03-11 11:21:16 +08:00
maxmoney21m fe9798fdfc Fix string 2023-03-11 10:50:37 +08:00
heyhoeandGitHub a8dc03c477 Correct identity claim for mastodon 2023-03-11 03:46:07 +09:00
maxmoney21m d4cee941ef Fix inverted QR scan 2023-03-11 01:22:23 +08:00
Vitor Pamplona 9adb376198 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-10 11:28:13 -05:00
maxmoney21m ae6cf15768 Replace mlkit with zxing QR scanner 2023-03-10 22:14:38 +08:00
Vitor Pamplona ccf2b06b10 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 15:42:22 -05:00
Vitor Pamplona f2f53c66bd Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 09:01:27 -05:00
Vitor Pamplona b93f28f09d Merge branch 'main' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
2023-03-08 17:47:34 -05:00
Vitor Pamplona 38df1ba6f7 Merge commit '40a6feab8a4a36813644c19ca034333b73e98132' into less_memory_test_branch 2023-03-08 17:35:30 -05:00
Vitor Pamplona 6c9aa9b6ff Merge commit '9c7a5b73b3a75a465f78c1d97726e66f0e3032a3' into less_memory_test_branch 2023-03-08 17:35:21 -05:00
Vitor Pamplona 26b581b13c Merge commit 'a41f2c38ec9683f0e9aaca3e4cffb2cd45f21145' into less_memory_test_branch 2023-03-08 17:34:40 -05:00
Vitor Pamplona 6a3cae1c8d Merge commit 'c7ba0bce5bbc3edd8ece9669751b110d59c40052' into less_memory_test_branch 2023-03-08 17:34:29 -05:00
Vitor Pamplona 40ac31983c Merge commit 'c9b859610e660f7e2d906594410bc7fbf2b1914a' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
2023-03-08 17:34:22 -05:00
Vitor Pamplona 8c25667d0f Organize imports to simplify merge 2023-03-08 17:29:36 -05:00
Vitor Pamplona 6acc842d4f Readding LocalCacheImport 2023-03-08 17:18:25 -05:00
Vitor Pamplona fc4b68b264 Merge commit 'f1b6927bb37d1c9887fb48aa5655a6d2df558e81' into less_memory_test_branch
# Conflicts:
#	app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt
2023-03-08 17:17:03 -05:00
Vitor Pamplona 9470560002 Testing Lint Commit 2023-03-08 17:07:56 -05:00
Vitor Pamplona cd950ee946 Merge commit '2d92e1d44491d62005e45adcf3dc1c5a403c386f' into less_memory_test_branch 2023-03-08 17:04:24 -05:00
Vitor Pamplona f9f68e2a4f Merge commit '691b8225035d8fe59539a971a85b1df2f2f84ebe' into less_memory_test_branch 2023-03-08 17:03:42 -05:00
Vitor Pamplona 3fd656da9f Removes several Indexes from User and Notes to see if we can reduce App's memory usage. 2023-03-08 08:47:33 -05:00
198 changed files with 14354 additions and 3368 deletions
+102 -12
View File
@@ -28,10 +28,19 @@ jobs:
- name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
releaseDirectory: app/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -40,10 +49,19 @@ jobs:
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
- name: Sign APK
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
releaseDirectory: app/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -60,24 +78,96 @@ jobs:
draft: false
prerelease: false
- name: Upload APK Asset
id: upload-release-asset-apk
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref_name }}.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload AAB Asset
id: upload-release-asset-aab
- name: Upload Play APK x86 Asset
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/release/app-release.aab
asset_name: app-release-${{ github.ref_name }}.aab
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
+42 -19
View File
@@ -1,5 +1,3 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
@@ -11,9 +9,7 @@
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
# Built application files
@@ -36,6 +32,7 @@ out/
# release/
# Gradle files
.gradle
.gradle/
build/
@@ -48,25 +45,51 @@ proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
captures/
.navigation/
*.ipr
*~
*.swp
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
*.iws
/out/
deploymentTargetDropdown.xml
render.experimental.xml
# User-specific configurations
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
.idea/**/compiler.xml
.idea/**/copyright/profiles_settings.xml
.idea/**/encodings.xml
.idea/**/misc.xml
.idea/**/modules.xml
.idea/**/scopes/scope_settings.xml
.idea/**/dictionaries
.idea/**/vcs.xml
.idea/**/jsLibraryMappings.xml
.idea/**/datasources.xml
.idea/**/dataSources.ids
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/assetWizardSettings.xml
.idea/**/gradle.xml
.idea/**/jarRepositories.xml
.idea/**/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+1
View File
@@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+69 -24
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 91
versionName "0.24.2"
versionCode 109
versionName "0.30.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -33,19 +33,46 @@ android {
resValue "string", "app_name", "@string/app_name_debug"
}
}
flavorDimensions "channel"
productFlavors {
play {
getIsDefault().set(true)
dimension "channel"
}
fdroid {
dimension "channel"
}
}
splits {
abi {
enable true
reset()
include "x86", "x86_64"
universalApk true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -66,10 +93,10 @@ dependencies {
implementation("androidx.navigation:navigation-compose:$nav_version")
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
implementation 'androidx.compose.material:material:1.4.0-beta02'
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
implementation "androidx.compose.material:material:$compose_ui_version"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
@@ -77,12 +104,11 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
@@ -100,9 +126,6 @@ dependencies {
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Robohash for Avatars
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
@@ -133,28 +156,18 @@ dependencies {
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
// Local Barcode Scanning model
// The idea is to make it work for degoogled phones
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
playImplementation 'com.google.mlkit:translate:17.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
@@ -171,3 +184,35 @@ dependencies {
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}
@@ -77,4 +77,13 @@ class TranslationsTest {
"pt"
)
}
@Test
fun testNostrEvents() {
assertTranslateContains(
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
"en"
)
}
}
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
+7
View File
@@ -13,6 +13,7 @@
<application
android:allowBackup="false"
android:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
@@ -22,6 +23,7 @@
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
@@ -45,6 +47,11 @@
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
</application>
</manifest>
@@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.app.Application
class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: Amethyst
private set
}
}
@@ -1,20 +1,26 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
fun preferences(context: Context): EncryptedSharedPreferences {
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create(
context,
PREFERENCES_NAME,
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
@@ -9,55 +11,193 @@ import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.toHex
import nostr.postr.toNpub
import java.io.File
import java.util.Locale
class LocalPreferences(context: Context) {
private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
// To use plaintext SharedPreferences for debugging, set this to true
// It will only apply in Debug builds
private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
private val encryptedPreferences = EncryptedStorage.preferences(context)
private val gson = GsonBuilder().create()
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
)
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
encryptedPreferences.all.keys.forEach { remove(it) }
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
if (npub !in accounts) {
accounts.add(npub)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
/**
* Deletes the npub-specific shared preference file
*/
private fun deleteUserPreferenceFile(npub: String) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
}
}
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return EncryptedStorage.preferences(npub)
}
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file,
* and switches the user to the first account in the list if it exists
*
* We need to use `commit()` to write changes to disk and release the file
* lock so that it can be deleted. If we use `apply()` there is a race
* condition and the file will probably not be deleted
*/
@SuppressLint("ApplySharedPref")
fun updatePrefsForLogout(npub: String) {
val userPrefs = encryptedPreferences(npub)
userPrefs.edit().clear().commit()
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
}
}
fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
}
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
encryptedPreferences(currentAccount).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
@@ -73,9 +213,19 @@ class LocalPreferences(context: Context) {
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
}
} catch (e: Throwable) {
e.printStackTrace()
null
}
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
Event.gson.fromJson(it, Event::class.java)
.getRefinedEvent(true) as ContactListEvent
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -84,43 +234,86 @@ class LocalPreferences(context: Context) {
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
} ?: mapOf<String, String>()
gson.fromJson(
it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
mapOf<String, String>()
mapOf()
}
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
hideDeleteRequestInfo,
latestContactList
)
} else {
return null
}
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
)
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
encryptedPreferences(currentAccount).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
encryptedPreferences(currentAccount).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
val stringPrefs = listOf(
PrefKeys.NOSTR_PRIVKEY,
PrefKeys.NOSTR_PUBKEY,
PrefKeys.RELAYS,
PrefKeys.LANGUAGE_PREFS,
PrefKeys.TRANSLATE_TO,
PrefKeys.ZAP_AMOUNTS,
PrefKeys.LATEST_CONTACT_LIST
)
val stringSetPrefs = listOf(
PrefKeys.FOLLOWING_CHANNELS,
PrefKeys.HIDDEN_USERS,
PrefKeys.DONT_TRANSLATE_FROM
)
encryptedPreferences().apply {
val appPrefs = this
encryptedPreferences(npub).edit().apply {
val userPrefs = this
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
}
}
@@ -1,36 +1,32 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData()
}
}
}
fun load(route: String, context: Context): Long {
fun load(route: String): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route)
lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
@@ -42,23 +38,14 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
fun refresh() {
@@ -1,167 +0,0 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
import name.neuhalfen.projects.android.robohash.handle.Handle
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
import name.neuhalfen.projects.android.robohash.paths.Configuration
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
import java.util.UUID
object RoboHashCache {
lateinit var robots: MyRoboHash
lateinit var defaultAvatar: ImageBitmap
@Synchronized
fun get(context: Context, hash: String): ImageBitmap {
if (!this::robots.isInitialized) {
robots = MyRoboHash(context)
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
}
return defaultAvatar
}
}
/**
* Recreates RoboHash to use a custom configuration
*/
class MyRoboHash(context: Context) {
private val configuration: Configuration = ModifiedSet1Configuration()
private val repository: ImageRepository
private val hashing = VariableSizeHashing(configuration.bucketSizes)
// Optional
private var memoryCache: LruCache<String, Bitmap>? = null
init {
repository = ImageRepository(context.assets)
}
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
this.memoryCache = memoryCache
}
fun calculateHandleFromUUID(uuid: UUID?): Handle {
val data = hashing.createBuckets(uuid)
return handleFactory.calculateHandle(data)
}
fun imageForHandle(handle: Handle): Bitmap {
if (null != memoryCache) {
val cached = memoryCache!![handle.toString()]
if (null != cached) return cached
}
val bucketValues = handle.bucketValues()
val paths = configuration.convertToFacetParts(bucketValues)
val sampleSize = 1
val buffer = repository.createBuffer(configuration.width(), configuration.height())
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
val merged = Canvas(target)
val paint = Paint(0)
// The first image is not added as copy form the buffer
for (i in paths.indices) {
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
}
repository.returnBuffer(buffer)
if (null != memoryCache) {
memoryCache!!.put(handle.toString(), target)
}
return target
}
companion object {
private val handleFactory = HandleFactory()
}
}
/**
* Custom configuration to avoid the use of String.format in the GeneratePath
* This uses the default location and ends up encoding number in the local language
*/
class ModifiedSet1Configuration : Configuration {
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
require(bucketValues.size == BUCKET_COUNT)
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
val paths = mutableListOf<String>()
// e.g.
// blue face #2
// blue nose #7
// blue
val firstFacetBucket = BUCKET_COLOR + 1
for (facet in 0 until FACET_COUNT) {
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
}
return paths.toTypedArray()
}
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
// TODO: Make more efficient
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
}
override fun getBucketSizes(): ByteArray {
return BUCKET_SIZES
}
override fun width(): Int {
return 300
}
override fun height(): Int {
return 300
}
companion object {
private const val ROOT = "sets/set1"
private const val BUCKET_COLOR = 0
private const val COLOR_COUNT = 10
private const val BODY_COUNT = 10
private const val FACE_COUNT = 10
private const val MOUTH_COUNT = 10
private const val EYES_COUNT = 10
private const val ACCESSORY_COUNT = 10
private const val BUCKET_COUNT = 6
private const val FACET_COUNT = 5
private val BUCKET_SIZES = byteArrayOf(
COLOR_COUNT.toByte(),
BODY_COUNT.toByte(),
FACE_COUNT.toByte(),
MOUTH_COUNT.toByte(),
EYES_COUNT.toByte(),
ACCESSORY_COUNT.toByte()
)
private val INT_TO_COLOR = arrayOf(
"blue",
"brown",
"green",
"grey",
"orange",
"pink",
"purple",
"red",
"white",
"yellow"
)
private val FACET_PATH_TEMPLATES = arrayOf(
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
)
}
}
@@ -71,6 +71,7 @@ object ServiceManager {
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@@ -22,17 +24,14 @@ 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 kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Persona
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
@@ -48,6 +47,7 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
@OptIn(DelicateCoroutinesApi::class)
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
@@ -57,7 +57,9 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var hideDeleteRequestInfo: Boolean = false,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -88,10 +90,12 @@ class Account(
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
followsTags,
relays,
loggedIn.privKey!!
)
@@ -99,7 +103,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
@@ -148,27 +152,41 @@ class Account(
}
}
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
fun createZapRequestFor(note: Note, message: String = ""): LnZapRequestEvent? {
if (!isWriteable()) return null
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
}
return null
}
fun hasWalletConnectSetup(): Boolean {
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(lnInvoice: String) {
if (!isWriteable()) return
zapPaymentRequest?.let {
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
Client.send(event, it.relayUri)
}
}
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user.pubkeyHex)
}
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
}
fun report(note: Note, type: ReportEvent.ReportType) {
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
@@ -183,7 +201,7 @@ class Account(
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
Client.send(event)
LocalCache.consume(event, null)
}
@@ -243,18 +261,49 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
val event = if (contactList != null) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays,
loggedIn.privKey!!
)
@@ -268,12 +317,14 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
@@ -282,10 +333,30 @@ class Account(
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
fun unfollow(tag: String) {
if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex }
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { !it.equals(tag, ignoreCase = true) },
contactList.relays(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = 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() }
@@ -294,8 +365,10 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
@@ -317,7 +390,7 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -357,6 +430,116 @@ class Account(
joinChannel(event.id)
}
fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addPublicBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun removePrivateBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun removePublicBookmark(note: Note) {
if (!isWriteable()) return
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun isInPrivateBookmarks(note: Note): Boolean {
if (!isWriteable()) return false
if (note is AddressableNote) {
return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!)
?.contains(note.address) == true
} else {
return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!)
?.contains(note.idHex) == true
}
}
fun isInPublicBookmarks(note: Note): Boolean {
if (!isWriteable()) return false
if (note is AddressableNote) {
return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true
} else {
return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true
}
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
live.invalidateData()
@@ -390,6 +573,12 @@ class Account(
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Nip47URI?) {
zapPaymentRequest = newServer
live.invalidateData()
saveable.invalidateData()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
@@ -452,7 +641,7 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.follows().isNullOrEmpty()) return
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
@@ -463,7 +652,7 @@ class Account(
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().relays?.map {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
} ?: return null
@@ -499,15 +688,27 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet() ?: emptySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(userProfile().follows) < 5
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(userProfile().follows) < 5 // if it has 5 reports by reliable users
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
fun isFollowing(user: User): Boolean {
return user.pubkeyHex in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
@@ -520,7 +721,7 @@ class Account(
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
@@ -530,8 +731,9 @@ class Account(
return (
note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports
(
note.author?.reportsBy(followsPlusMe) ?: emptyList()
) + innerReports
).toSet()
}
@@ -542,8 +744,13 @@ class Account(
saveable.invalidateData()
}
fun setHideDeleteRequestInfo() {
hideDeleteRequestInfo = true
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
}
@@ -573,7 +780,7 @@ class Account(
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
@@ -584,22 +791,15 @@ class Account(
}
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Default) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
fun refresh() {
@@ -4,14 +4,8 @@ import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.Event
import kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
@@ -60,23 +54,14 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
private fun refresh() {
@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
class Channel(val idHex: String) {
@@ -27,12 +29,16 @@ class Channel(val idHex: String) {
notes[note.idHex] = note
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.refresh()
live.invalidateData()
}
fun profilePicture(): String? {
@@ -67,7 +73,18 @@ class Channel(val idHex: String) {
}
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
fun refresh() {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(ChannelState(channel))
}
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
}
class HashtagIcon(
val icon: Int,
val description: String,
val color: Color,
val modifier: Modifier
)
@@ -4,11 +4,11 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -16,7 +16,6 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
@@ -28,21 +27,15 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
object LocalCache {
val metadataParser = jacksonObjectMapper()
@@ -57,13 +50,10 @@ object LocalCache {
val addressables = ConcurrentHashMap<String, AddressableNote>()
fun checkGetOrCreateUser(key: String): User? {
return try {
val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex
getOrCreateUser(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
null
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
}
return null
}
@Synchronized
@@ -79,13 +69,10 @@ object LocalCache {
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateNote(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create note: $key", e)
null
if (isValidHexNpub(key)) {
return getOrCreateNote(key)
}
return null
}
@Synchronized
@@ -98,12 +85,19 @@ object LocalCache {
}
fun checkGetOrCreateChannel(key: String): Channel? {
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
}
return null
}
private fun isValidHexNpub(key: String): Boolean {
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateChannel(key)
Hex.decode(key).toNpub()
true
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
Log.e("LocalCache", "Invalid Key to create user: $key", e)
false
}
}
@@ -164,6 +158,18 @@ object LocalCache {
}
}
fun consume(event: BookmarkListEvent) {
val user = getOrCreateUser(event.pubKey)
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
if (event.dTag() == "bookmark") {
user.updateBookmark(event)
}
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -188,24 +194,15 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -233,22 +230,13 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
refreshObservers()
}
}
@@ -261,7 +249,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
}
@@ -278,7 +266,7 @@ object LocalCache {
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList(), replyTo)
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
@@ -295,15 +283,9 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardees, awardDefinition)
// Adds notifications to users.
awardees.forEach {
it.addTaggedPost(note)
}
note.loadEvent(event, author, awardDefinition)
// Replies of an Badge Definition are Award Events
awardDefinition.forEach {
@@ -313,94 +295,18 @@ object LocalCache {
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
private fun tagsWithoutCitations(event: TextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
private fun tagsWithoutCitations(event: LongTextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// Log.d("RR", event.toJson())
// // Log.d("RR", event.toJson())
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val follows = event.follows()
val follows = event.unverifiedFollowKeySet()
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
user.updateFollows(
follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
// e.printStackTrace()
null
}
}.filterNotNull().toSet(),
event.createdAt
)
// Saves relay list only if it's a user that is currently been seen
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
Log.w("Relay List Parser", "Relay import issue ${e.message}", e)
e.printStackTrace()
}
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
@@ -422,10 +328,10 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
@@ -444,13 +350,11 @@ object LocalCache {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
deleteNote.mentions?.forEach { user ->
user.removeTaggedPost(deleteNote)
user.removeReport(deleteNote)
}
val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) }
deleteNote.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(deleteNote)
mentions?.forEach { user ->
user.removeReport(deleteNote)
}
// Counts the replies
@@ -462,6 +366,19 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
@@ -482,23 +399,14 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Prepares user's profile view.
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
@@ -514,22 +422,13 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
if (
event.content == "" ||
event.content == "+" ||
@@ -567,9 +466,9 @@ object LocalCache {
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
@@ -585,21 +484,19 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
}
} else {
// older data, does nothing
refreshObservers()
}
}
@@ -617,7 +514,7 @@ object LocalCache {
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
note.loadEvent(event, author, emptyList())
refreshObservers()
}
@@ -653,23 +550,14 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -678,9 +566,11 @@ object LocalCache {
refreshObservers()
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelHideMessageEvent) {
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelMuteUserEvent) {
}
@@ -696,9 +586,12 @@ object LocalCache {
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (zapRequest == null) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
@@ -707,14 +600,6 @@ object LocalCache {
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
@@ -734,18 +619,10 @@ object LocalCache {
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
@@ -795,7 +672,7 @@ object LocalCache {
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach {
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
toBeRemoved.forEach {
@@ -803,15 +680,12 @@ object LocalCache {
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { replyingNote ->
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
}
@@ -820,40 +694,6 @@ object LocalCache {
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind() == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
@@ -866,14 +706,6 @@ object LocalCache {
toBeRemoved.forEach {
it.author?.removeNote(it)
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
@@ -889,6 +721,18 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(userAccount: Account) {
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
it.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -897,26 +741,18 @@ object LocalCache {
}
}
class LocalCacheLiveData(val cache: LocalCache) : LiveData<LocalCacheState>(LocalCacheState(cache)) {
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(50)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
private fun refresh() {
@@ -4,21 +4,15 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -35,7 +29,6 @@ open class Note(val idHex: String) {
// They are immutable after that.
var event: EventInterface? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
@@ -53,7 +46,7 @@ open class Note(val idHex: String) {
var relays = setOf<String>()
private set
var lastReactionsDownloadTime: Long? = null
var lastReactionsDownloadTime: Map<String, Long> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
@@ -68,14 +61,13 @@ open class Note(val idHex: String) {
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address() = (event as? LongTextNoteEvent)?.address()
open fun address(): ATag? = null
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
@@ -215,15 +207,15 @@ open class Note(val idHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -237,13 +229,42 @@ open class Note(val idHex: String) {
}.sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue = try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() ||
(
author?.reports?.values?.filter {
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
}?.isNotEmpty() ?: false
} ?: false
)
}
@@ -351,23 +372,14 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
private fun refresh() {
@@ -7,8 +7,8 @@ data class RelaySetupInfo(
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
)
@@ -2,58 +2,40 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import nostr.postr.toNpub
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class Badges(val definition: Note, val awardees: Set<Note>)
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var updatedFollowsAt: Long = 0
var latestContactList: ContactListEvent? = null
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
var latestBookmarkList: BookmarkListEvent? = null
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
private set
var latestReportTime: Long = 0
var latestEOSEs: Map<String, Long> = emptyMap()
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
@@ -89,54 +71,32 @@ class User(val pubkeyHex: String) {
return info?.picture
}
fun follow(user: User, followedAt: Long) {
follows = follows + user
user.followers = user.followers + this
fun updateBookmark(event: BookmarkListEvent) {
if (event.id == latestBookmarkList?.id) return
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveSet?.follows?.invalidateData()
}
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
// No need for Listener yet
}
}
fun removeTaggedPost(note: Note) {
taggedPosts = taggedPosts - note
liveSet?.relays?.invalidateData()
}
fun addNote(note: Note) {
@@ -164,11 +124,6 @@ class User(val pubkeyHex: String) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
}
val reportTime = note.createdAt() ?: 0
if (reportTime > latestReportTime) {
latestReportTime = reportTime
}
}
fun removeReport(deleteNote: Note) {
@@ -209,15 +164,15 @@ class User(val pubkeyHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -232,6 +187,7 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
@@ -240,6 +196,15 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
liveSet?.messages?.invalidateData()
}
}
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
@@ -254,22 +219,6 @@ class User(val pubkeyHex: String) {
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
info?.latestMetadata = latestMetadata
@@ -295,7 +244,51 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return follows.contains(user)
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
} ?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
@@ -340,6 +333,7 @@ class UserLiveSet(u: User) {
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val badges: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
fun isInUse(): Boolean {
return follows.hasObservers() ||
@@ -349,7 +343,8 @@ class UserLiveSet(u: User) {
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
badges.hasObservers()
badges.hasObservers() ||
bookmarks.hasObservers()
}
}
@@ -394,24 +389,15 @@ class UserMetadata {
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
bundler.invalidate()
}
private fun refresh() {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -25,10 +26,10 @@ class Nip05Verifier {
return null
}
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
@@ -42,7 +43,10 @@ class Nip05Verifier {
withContext(Dispatchers.IO) {
try {
val request: Request = Request.Builder().url(url).build()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
// Rename to the corect nip number when ready.
object Nip47 {
fun parse(uri: String): Nip47URI {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
val pubkeyHex = try {
decodePublicKey(pubkey).toHexKey()
} catch (e: Exception) {
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
}
val relay = url.getQueryParameter("relay")
val secret = url.getQueryParameter("secret")
return Nip47URI(pubkeyHex, relay, secret)
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -51,6 +52,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
@@ -87,7 +99,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter()
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
).ifEmpty { null }
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -8,13 +9,39 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
fun loadMessagesBetween(account: Account, channelId: String) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
fun clear() {
account = null
channel = null
}
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
return TypedFilter(
@@ -32,6 +59,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
messagesChannel.typedFilters = listOfNotNull(
createMessagesToChannelFilter(),
createMessagesByMeToChannelFilter()
).ifEmpty { null }
}
}
@@ -64,7 +64,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 100 // Remember to consider spam that is being removed from the UI
limit = 25 // Remember to consider spam that is being removed from the UI
)
)
}
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -26,16 +27,13 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
@@ -67,6 +65,7 @@ abstract class NostrDataSource(val debugName: String) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is BookmarkListEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
@@ -115,7 +114,7 @@ abstract class NostrDataSource(val debugName: String) {
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(Date().time / 1000)
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
}
}
@@ -140,7 +139,7 @@ abstract class NostrDataSource(val debugName: String) {
}
}
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
@@ -152,24 +151,16 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateFilters() {
if (handlerWaiting.getAndSet(true)) return
private val bundler = BundledUpdate(250, Dispatchers.IO) {
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
try {
delay(200)
resetFiltersSuspend()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
fun invalidateFilters() {
bundler.invalidate()
}
fun resetFilters() {
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
hashtagToWatch = tag
invalidateFilters()
}
}
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -18,6 +19,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
invalidateFilters()
}
@OptIn(DelicateCoroutinesApi::class)
override fun start() {
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
@@ -27,6 +29,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
super.start()
}
@OptIn(DelicateCoroutinesApi::class)
override fun stop() {
super.stop()
if (this::account.isInitialized) {
@@ -37,10 +40,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val follows = account.followingKeySet()
val followKeys = follows.map {
it.pubkeyHex.substring(0, 6)
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -55,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}
@@ -18,7 +18,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString == null) {
if (mySearchString.isNullOrBlank()) {
return null
}
@@ -33,10 +33,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val now = Date().time / 1000
return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.mapNotNull {
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
@@ -64,10 +61,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val now = Date().time / 1000
return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.mapNotNull {
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
@@ -90,10 +84,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.map {
return reactionsToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
@@ -145,9 +136,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
)
}
val singleEventChannel = requestNewChannel { time ->
val singleEventChannel = requestNewChannel { time, relayUrl ->
eventsToWatch.forEach {
it.lastReactionsDownloadTime = time
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, time)
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
@@ -34,13 +34,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestReportTime
since = it.latestEOSEs
)
)
}
}
val userChannel = requestNewChannel() {
val userChannel = requestNewChannel() { time, relayUrl ->
usersToWatch.forEach {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, time)
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
)
}
val loadEventsChannel = requestNewChannel() {
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
@@ -4,10 +4,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -41,7 +43,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -90,6 +92,17 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
)
}
fun createBookmarksFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
@@ -111,7 +124,8 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter()
createReceivedAwardsFilter(),
createBookmarksFilter()
).ifEmpty { null }
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,6 +13,7 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.net.URLEncoder
class LightningAddressResolver {
@@ -51,7 +53,10 @@ class LightningAddressResolver {
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@@ -91,7 +96,10 @@ class LightningAddressResolver {
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder().url(url).build()
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@@ -122,14 +130,16 @@ class LightningAddressResolver {
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(
lnaddress,
onSuccess = {
onSuccess = { lnAddressJson ->
onProgress(0.4f)
val lnurlp = try {
mapper.readTree(it)
mapper.readTree(lnAddressJson)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
@@ -143,13 +153,15 @@ class LightningAddressResolver {
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
callback?.let { callback ->
callback?.let { cb ->
fetchLightningInvoice(
callback,
cb,
milliSats,
message,
if (allowsNostr) nostrRequest else null,
onSuccess = {
onProgress(0.6f)
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
@@ -158,7 +170,14 @@ class LightningAddressResolver {
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
// Forces LN Invoice amount to be the requested amount.
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
onProgress(0.7f)
onSuccess(pr)
} else {
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
}
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
},
onError = onError
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.service.lnurl
import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
Pattern.CASE_INSENSITIVE
)
/**
* Finds LN withdrawal in the provided input string and returns it.
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
* It will only return the first withdrawal found in the input.
*
* @return the invoice if it was found. null for null input or if no invoice is found
*/
fun findWithdrawal(input: String?): String? {
if (input == null) {
return null
}
val matcher = withdrawalPattern.matcher(input)
return if (matcher.find()) {
matcher.group()
} else {
null
}
}
}
@@ -0,0 +1,50 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
}
@@ -0,0 +1,117 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 30001
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -14,7 +14,7 @@ class ChannelCreateEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channelInfo() = try {
fun channelInfo(): ChannelData = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
@@ -18,17 +18,16 @@ class ChannelHideMessageEvent(
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val content = reason
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {
listOf("e", it)
} ?: emptyList()
val id = generateId(pubKey, createdAt, kind, tags, content)
val id = generateId(pubKey, createdAt, kind, tags, reason)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, reason, sig.toHexKey())
}
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -17,28 +18,60 @@ class ContactListEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun follows() = try {
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
null
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
}.toSet()
}
fun relayUse() = try {
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
it.getOrNull(2)
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type)
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
} else {
null
}
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
null
}
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
@@ -51,7 +84,10 @@ class ContactListEvent(
} else {
listOf("p", it.pubKeyHex)
}
} + followTags.map {
listOf("t", it)
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
import nostr.postr.toHex
import java.lang.reflect.Type
import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@@ -37,6 +38,53 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
if (id[i] == '0') {
rank += 4
} else if (id[i] in '4'..'7') {
rank += 1
break
} else if (id[i] in '2'..'3') {
rank += 2
break
} else if (id[i] == '1') {
rank += 3
break
} else {
break
}
}
return rank
}
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
} catch (e: Exception) {
null
}
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
@@ -165,7 +213,7 @@ open class Event(
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
@@ -175,6 +223,7 @@ open class Event(
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
@@ -196,7 +245,15 @@ open class Event(
tags,
content
)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
interface EventInterface {
fun id(): HexKey
@@ -22,4 +23,14 @@ interface EventInterface {
fun checkSignature()
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
fun getPoWRank(): Int
}
@@ -23,15 +23,6 @@ class LnZapEvent(
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun taggedAddresses(): List<ATag> = tags
.filter { it.firstOrNull() == "a" }
.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
override fun amount(): BigDecimal? {
return amount
}
@@ -0,0 +1,55 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapPaymentRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun lnInvoice(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 23194
fun create(
lnInvoice: String,
walletServicePubkey: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = Utils.encrypt(
lnInvoice,
privateKey,
walletServicePubkey.toByteArray()
)
val tags = mutableListOf<List<String>>()
tags.add(listOf("p", walletServicePubkey))
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapPaymentRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -15,18 +15,18 @@ class LnZapRequestEvent(
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 9734
fun create(originalNote: EventInterface, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
@@ -42,8 +42,14 @@ class LnZapRequestEvent(
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
message: String,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("p", userHex),
@@ -12,16 +12,7 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
@@ -28,9 +28,7 @@ abstract class IdentityClaim(
companion object {
fun create(platformIdentity: String, proof: String): IdentityClaim? {
val platformIdentity = platformIdentity.split(':')
val platform = platformIdentity[0]
val identity = platformIdentity[1]
val (platform, identity) = platformIdentity.split(':')
return when (platform.lowercase()) {
GitHubIdentity.platform -> GitHubIdentity(identity, proof)
@@ -128,7 +126,7 @@ class MastodonIdentity(
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://").split("?")[0].split("/")
return MastodonIdentity(path[0], path[1])
return MastodonIdentity("${path[0]}/${path[1]}", path[2])
} catch (e: Exception) {
null
}
@@ -169,16 +167,16 @@ class MetadataEvent(
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
val content = contactMetaData
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
identities?.forEach {
identities.forEach {
tags.add(listOf("i", it.platformIdentity(), it.proof))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val id = generateId(pubKey, createdAt, kind, tags, contactMetaData)
val sig = Utils.sign(id, privateKey)
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, contactMetaData, sig.toHexKey())
}
}
}
@@ -0,0 +1,110 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 10000
fun create(
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): MuteListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val tags = mutableListOf<List<String>>()
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return MuteListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -16,12 +16,6 @@ class ReactionEvent(
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 7
@@ -19,9 +19,9 @@ class ReportEvent(
private fun defaultReportType(): ReportType {
// Works with old and new structures for report.
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
}
if (reportType == null) {
reportType = ReportType.SPAM
@@ -34,7 +34,7 @@ class ReportEvent(
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
)
}
@@ -43,23 +43,20 @@ class ReportEvent(
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
)
}
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 1984
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
fun create(
reportedPost: EventInterface,
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
@@ -17,12 +17,6 @@ class RepostEvent(
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun containedPost() = try {
fromJson(content, Client.lenient)
@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@@ -12,21 +15,20 @@ class TextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 1
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
fun create(
msg: String,
replyTos: List<String>?,
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
@@ -38,9 +40,22 @@ class TextNoteEvent(
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
}
extraTags?.forEach {
tags.add(listOf("t", it))
}
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
fun findURLs(text: String): List<String> {
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
}
@@ -1,35 +1,43 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, RELAY, ADDRESS
USER, NOTE, EVENT, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String, val relay: String? = null)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:") ?: return null
if (uri == null) return null
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
try {
val matcher = nip19regex.matcher(uri)
matcher.find()
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) ?: "" // additional chars
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 $uri: ${e.message}")
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
@@ -68,7 +76,7 @@ object Nip19 {
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
return Return(Type.EVENT, hex, relay)
}
private fun nrelay(bytes: ByteArray): Return? {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -63,8 +64,24 @@ object Client : RelayPool.Listener {
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface) {
RelayPool.send(signedEvent)
fun send(signedEvent: EventInterface, relay: String? = null) {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelay = relays.filter { it.url == relay }
if (useConnectedRelay.isNotEmpty()) {
useConnectedRelay.forEach {
it.send(signedEvent)
}
} else {
/** temporary connection */
Relay(relay, false, true, emptySet()).requestAndWatch() {
it.send(signedEvent)
it.disconnect()
}
}
}
}
fun close(subscriptionId: String) {
@@ -78,6 +95,7 @@ object Client : RelayPool.Listener {
RelayPool.unloadRelays()
}
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
@@ -86,6 +104,7 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
@@ -94,6 +113,7 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
@@ -102,6 +122,7 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
@@ -4,26 +4,19 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.vitorpamplona.amethyst.service.model.Event
import java.io.Serializable
import java.util.*
interface Filter {
fun match(event: Event): Boolean
fun toShortString(): String
}
class JsonFilter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Long? = null,
val since: Map<String, Long>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null
) : Filter, Serializable {
fun toJson(): String {
) {
fun toJson(forRelay: String? = null): String {
val jsonObject = JsonObject()
ids?.run {
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
@@ -40,7 +33,20 @@ class JsonFilter(
}
}
since?.run {
jsonObject.addProperty("since", since)
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
jsonObject.addProperty("since", relaySince)
}
} else {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
jsonObject.add("since", jsonObjectSince)
}
}
}
until?.run {
jsonObject.addProperty("until", until)
@@ -54,90 +60,7 @@ class JsonFilter(
return gson.toJson(jsonObject)
}
override fun match(event: Event): Boolean {
if (ids?.any { event.id == it } == false) return false
if (kinds?.any { event.kind == it } == false) return false
if (authors?.any { event.pubKey == it } == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
}
return true
}
override fun toString(): String = "JsonFilter${toJson()}"
override fun toShortString(): String {
val list = ArrayList<String>()
ids?.run {
list.add("ids")
}
authors?.run {
list.add("authors")
}
kinds?.run {
list.add("kinds[${kinds.joinToString()}]")
}
tags?.run {
list.add("tags")
}
since?.run {
list.add("since")
}
until?.run {
list.add("until")
}
limit?.run {
list.add("limit")
}
search?.run {
list.add("search")
}
return list.joinToString()
}
companion object {
val gson: Gson = GsonBuilder().create()
fun fromJson(json: String): JsonFilter {
val jsonFilter = gson.fromJson(json, JsonObject::class.java)
return fromJson(jsonFilter)
}
val declaredFields = JsonFilter::class.java.declaredFields.map { it.name }
fun fromJson(json: JsonObject): JsonFilter {
// sanity check
if (json.keySet().any { !(it.startsWith("#") || it in declaredFields) }) {
println("Filter $json contains unknown parameters.")
}
return JsonFilter(
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
authors = if (json.has("authors")) {
json.getAsJsonArray("authors")
.map { it.asString }
} else {
null
},
kinds = if (json.has("kinds")) {
json.getAsJsonArray("kinds")
.map { it.asInt }
} else {
null
},
tags = json
.entrySet()
.filter { it.key.startsWith("#") }
.associate {
it.key.substring(1) to it.value.asJsonArray.map { it.asString }
}
.ifEmpty { null },
since = if (json.has("since")) json.get("since").asLong else null,
until = if (json.has("until")) json.get("until").asLong else null,
limit = if (json.has("limit")) json.get("limit").asInt else null,
search = if (json.has("search")) json.get("search").asString else null
)
}
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import okhttp3.OkHttpClient
@@ -30,9 +31,10 @@ class Relay(
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventDownloadCounterInBytes = 0
var eventUploadCounterInBytes = 0
var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
@@ -52,25 +54,37 @@ class Relay(
@Synchronized
fun requestAndWatch() {
requestAndWatch {
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
if (socket != null) return
try {
val request = Request.Builder().url(url.trim()).build()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
@@ -78,9 +92,7 @@ class Relay(
when (type) {
"EVENT" -> {
// Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
listeners.forEach { it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) }
}
"EOSE" -> listeners.forEach {
// Log.w("Relay", "Relay onEOSE $url, $channel")
@@ -88,7 +100,6 @@ class Relay(
}
"NOTICE" -> listeners.forEach {
// Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
@@ -171,9 +182,10 @@ class Relay(
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
// println("FILTERSSENT ${url} ${request}")
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
}
}
} else {
@@ -198,8 +210,9 @@ class Relay(
fun send(signedEvent: EventInterface) {
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
}
@@ -246,3 +259,7 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}
@@ -7,12 +7,12 @@ import java.util.UUID
data class Subscription(
val id: String = UUID.randomUUID().toString().substring(0, 4),
val onEOSE: ((Long) -> Unit)? = null
val onEOSE: ((Long, String) -> Unit)? = null
) {
var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
fun updateEOSE(time: Long, relay: String) {
onEOSE?.let { it(time, relay) }
}
fun toJson(): String {
@@ -27,22 +27,28 @@ class TypedFilter(
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
}
}
/*
Does not include since in the json comparison
filter.since?.run {
jsonObject.addProperty("since", filter.since)
}
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run {
jsonObject.addProperty("until", filter.until)
}
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -21,6 +24,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -33,6 +37,8 @@ class MainActivity : FragmentActivity() {
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
@@ -49,12 +55,14 @@ class MainActivity : FragmentActivity() {
.build()
}
LocalPreferences.migrateSingleUserPrefs()
setContent {
AmethystTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext))
AccountStateViewModel()
}
AccountScreen(accountStateViewModel, startingPage)
@@ -65,6 +73,7 @@ class MainActivity : FragmentActivity() {
Client.lenient = true
}
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
@@ -90,3 +99,13 @@ class MainActivity : FragmentActivity() {
ServiceManager.cleanUp()
}
}
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input).apply {
// Force only images and videos to be selectable
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}
}
@@ -3,12 +3,12 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okio.BufferedSource
import okio.IOException
@@ -31,6 +31,7 @@ object ImageSaver {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.get()
.url(url)
.build()
@@ -134,13 +135,8 @@ object ImageSaver {
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
private const val PICTURES_SUBDIRECTORY = "Amethyst " + BuildConfig.VERSION_NAME
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
@@ -18,13 +19,16 @@ object ImageUploader {
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
val client = OkHttpClient.Builder().build()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
category,
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
@@ -43,8 +47,9 @@ object ImageUploader {
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.post(requestBody)
.build()
@@ -16,7 +16,6 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
@@ -31,8 +30,6 @@ import com.vitorpamplona.amethyst.model.Channel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
val context = LocalContext.current.applicationContext
postViewModel.load(account, channel)
Dialog(
@@ -58,7 +55,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
PostButton(
onPost = {
postViewModel.create(context)
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
@@ -25,7 +24,7 @@ class NewChannelViewModel : ViewModel() {
}
}
fun create(context: Context) {
fun create() {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
@@ -208,7 +208,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { index, item ->
) { _, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
}
@@ -218,7 +218,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Row(modifier = Modifier.fillMaxWidth()) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(bottom = 10.dp)
) {
postViewModel.upload(it, context)
}
@@ -10,9 +10,12 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@@ -36,13 +39,19 @@ class NewPostViewModel : ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
} ?: {
replyTos = null
mentions = null
}
quote?.let {
@@ -81,6 +90,8 @@ class NewPostViewModel : ViewModel() {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
@@ -101,6 +112,10 @@ class NewPostViewModel : ViewModel() {
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
@@ -136,12 +151,16 @@ class NewPostViewModel : ViewModel() {
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
@@ -185,7 +204,7 @@ class NewPostViewModel : ViewModel() {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = "@${item.pubkeyNpub()}"
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
@@ -40,7 +40,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -58,12 +57,10 @@ import java.lang.Math.round
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val ctx = LocalContext.current.applicationContext
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account, ctx)
postViewModel.load(account)
}
Dialog(
@@ -83,13 +80,13 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear(ctx)
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create(ctx)
postViewModel.create()
onClose()
},
true
@@ -153,27 +150,27 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
Spacer(modifier = Modifier.size(30.dp))
Text(
text = stringResource(R.string.posts),
text = stringResource(R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(id = R.string.posts),
text = stringResource(id = R.string.bytes),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.errors),
@@ -186,7 +183,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(5.dp))
Text(
text = "Spam",
text = stringResource(R.string.spam),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -374,10 +371,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
text = "${countToHumanReadable(item.downloadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -402,10 +399,10 @@ fun ServerConfig(
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
text = "${countToHumanReadable(item.uploadCountInBytes)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -421,7 +418,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -436,7 +433,7 @@ fun ServerConfig(
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
@@ -18,22 +17,22 @@ class NewRelayListViewModel : ViewModel() {
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
fun load(account: Account) {
this.account = account
clear(ctx)
clear()
}
fun create(ctx: Context) {
fun create() {
relays.let {
account.saveRelayList(it.value)
}
clear(ctx)
clear()
}
fun clear(ctx: Context) {
fun clear() {
_relays.update {
var relayFile = account.userProfile().relays
var relayFile = account.userProfile().latestContactList?.relays()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
@@ -50,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
} else {
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}.sortedBy { it.downloadCountInBytes }.reversed()
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,6 +19,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
@@ -30,9 +32,14 @@ import com.vitorpamplona.amethyst.model.Account
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
@@ -141,6 +148,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForPicture(it, context)
}
},
singleLine = true
)
@@ -157,6 +173,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = postViewModel.isUploadingImageForBanner,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
postViewModel.uploadForBanner(it, context)
}
},
singleLine = true
)
@@ -1,13 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
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.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.StringWriter
@@ -30,6 +37,10 @@ class NewUserMetadataViewModel : ViewModel() {
val github = mutableStateOf("")
val mastodon = mutableStateOf("")
var isUploadingImageForPicture by mutableStateOf(false)
var isUploadingImageForBanner by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
fun load(account: Account) {
this.account = account
@@ -127,4 +138,49 @@ class NewUserMetadataViewModel : ViewModel() {
github.value = ""
mastodon.value = ""
}
fun uploadForPicture(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForPicture = it
},
onUploaded = {
picture.value = it
}
)
}
fun uploadForBanner(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForBanner = it
},
onUploaded = {
banner.value = it
}
)
}
fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
onUploading(true)
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
@@ -3,24 +3,39 @@ package com.vitorpamplona.amethyst.ui.actions
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onImageChosen: (Uri) -> Unit
) {
val cameraPermissionState =
@@ -43,64 +58,111 @@ fun UploadFromGallery(
}
}
)
} else {
Box() {
TextButton(
modifier = Modifier
.align(Alignment.TopCenter),
enabled = !isUploading,
onClick = {
showGallerySelect = true
}
) {
Icon(
painter = painterResource(id = R.drawable.ic_add_photo),
contentDescription = stringResource(id = R.string.upload_image),
modifier = Modifier
.height(20.dp)
.padding(end = 8.dp),
tint = MaterialTheme.colors.primary
)
}
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
}
}
UploadBoxButton(isUploading, tint, modifier) {
showGallerySelect = true
}
} else {
Column {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading
) {
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
UploadBoxButton(isUploading, tint, modifier) {
cameraPermissionState.launchPermissionRequest()
}
}
}
@Composable
private fun UploadBoxButton(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit
) {
Box() {
IconButton(
modifier = modifier.align(Alignment.Center),
enabled = !isUploading,
onClick = {
onClick()
}
) {
if (!isUploading) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringResource(id = R.string.upload_image),
modifier = Modifier.height(25.dp),
tint = tint
)
} else {
LoadingAnimation()
}
}
}
}
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: List<Color> = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
),
animationDuration: Int = 1000
) {
val infiniteTransition = rememberInfiniteTransition()
val rotateAnimation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = animationDuration,
easing = LinearEasing
)
)
)
CircularProgressIndicator(
modifier = Modifier
.size(size = indicatorSize)
.rotate(degrees = rotateAnimation)
.border(
width = 4.dp,
brush = Brush.sweepGradient(circleColors),
shape = CircleShape
),
progress = 1f,
strokeWidth = 1.dp,
color = MaterialTheme.colors.background // Set background color
)
}
@Composable
fun GallerySelect(
onImageUri: (Uri?) -> Unit = { }
) {
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
contract = GetMediaActivityResultContract(),
onResult = { uri: Uri? ->
onImageUri(uri)
hasLaunched.set(false)
}
)
@Composable
fun LaunchGallery() {
SideEffect {
launcher.launch("image/*")
if (!hasLaunched.getAndSet(true)) {
launcher.launch("*/*")
}
}
}
@@ -0,0 +1,46 @@
package com.vitorpamplona.amethyst.ui.components
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
val onUpdate: () -> Unit
) {
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
fun invalidate() {
if (onlyOneInBlock.getAndSet(true)) {
invalidatesAgain = true
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate()
delay(delay)
if (invalidatesAgain) {
onUpdate()
}
} finally {
withContext(NonCancellable) {
invalidatesAgain = false
onlyOneInBlock.set(false)
}
}
}
}
}
@@ -15,7 +15,7 @@ fun ClickableNoteTag(
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -7,10 +7,12 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@Composable
@@ -24,58 +26,72 @@ fun ClickableRoute(
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@$text "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
} else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
if (noteBase == null) {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.NOTE) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.EVENT) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
}
} else {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("@$clickablePart")
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) {
append("$suffix ")
}
},
onClick = { navController.navigate(route) }
)
}
@@ -12,7 +12,7 @@ fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
ClickableText(
text = AnnotatedString("$urlText "),
text = AnnotatedString(urlText),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -17,7 +17,7 @@ fun ClickableUserTag(
) {
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.core.content.ContextCompat
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$withdrawalString "),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
ContextCompat.startActivity(context, intent, null)
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
const val SHORT_TEXT_LENGTH = 350
@@ -43,35 +42,35 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
val text = if (showFullText) {
content
} else {
val (lnStart, lnEnd) = LnInvoiceUtil.locateInvoice(content)
if (lnStart < SHORT_TEXT_LENGTH && lnEnd > 0) {
content.take(lnEnd)
} else {
content.take(SHORT_TEXT_LENGTH)
}
content.take(whereToCut)
}
Box(contentAlignment = Alignment.BottomCenter) {
// CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Box {
RichTextViewer(
text,
canPreview,
modifier,
modifier.align(Alignment.TopStart),
tags,
backgroundColor,
accountViewModel,
navController
)
// }
if (content.length > 350 && !showFullText) {
if (content.length > whereToCut && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
@@ -130,7 +130,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
LightningAddressResolver().lnAddressInvoice(
lud16,
@@ -149,6 +149,8 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
},
onProgress = {
}
)
},
@@ -1,13 +1,16 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -17,8 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
@@ -32,7 +35,9 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -41,13 +46,16 @@ import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -61,6 +69,8 @@ fun isValidURL(url: String?): Boolean {
}
}
val richTextDefaults = RichTextStyle().resolveDefaults()
@Composable
fun RichTextViewer(
content: String,
@@ -71,8 +81,8 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
@@ -86,9 +96,13 @@ fun RichTextViewer(
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
@@ -117,6 +131,23 @@ fun RichTextViewer(
)
}
} else {
val imagesForPager = mutableListOf<String>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
// sequence of images will render in a slideview
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
imagesForPager.add(word)
}
if (videoExtension.matcher(removedParamsFromUrl).matches()) {
imagesForPager.add(word)
}
}
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
@@ -125,27 +156,39 @@ fun RichTextViewer(
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
ZoomableImageView(word, imagesForPager)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
ZoomableImageView(word, imagesForPager)
} else {
UrlPreview(word, word)
UrlPreview(word, "$word ")
}
} else if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
@@ -159,12 +202,20 @@ fun RichTextViewer(
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else {
Text(
text = "$word ",
@@ -191,19 +242,7 @@ fun isBechLink(word: String): Boolean {
@Composable
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:$word"
}
val nip19Route = try {
Nip19.uriToRoute(uri)
} catch (e: Exception) {
null
}
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
@@ -212,16 +251,85 @@ fun BechLink(word: String, navController: NavController) {
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (tag, suffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
)
if (hashtagIcon != null) {
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
val (index, extraCharacters) = try {
matcher.find()
matcher.group(1).toInt()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
println("Couldn't link tag $word")
null
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (index == null) {
@@ -236,6 +344,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
val user = userState.value?.user
if (user != null) {
ClickableUserTag(user, navController)
Text(text = "$extraCharacters ")
} else {
Text(text = "$word ")
}
@@ -251,7 +360,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(0.dp)
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -266,6 +375,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
)
} else {
ClickableNoteTag(note, navController)
Text(text = "$extraCharacters ")
}
} else {
// if here the tag is not a valid Nostr Hex
@@ -0,0 +1,314 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import okio.Buffer
import java.security.MessageDigest
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 val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
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 {
val hash = sha256.digest(msg.toByteArray())
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
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>"""
}
class HashImageFetcher(
private val context: Context,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): SourceResult {
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
return SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.crossfade(100)
.build()
}
}
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"/>"""
)
)
@@ -0,0 +1,109 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
@Composable
fun RobohashAsyncImage(
robot: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
onState: ((AsyncImagePainter.State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
AsyncImage(
model = Robohash.imageRequest(LocalContext.current, robot),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
@Composable
fun RobohashFallbackAsyncImage(
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
) {
val context = LocalContext.current
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
@Composable
fun RobohashAsyncImageProxy(
robot: String,
model: ResizeImage,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (model.url == null) {
RobohashAsyncImage(
robot = robot,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
RobohashFallbackAsyncImage(
robot = robot,
model = model.proxyUrl(),
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
@@ -0,0 +1,104 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
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.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
@OptIn(ExperimentalPagerApi::class)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
pagerState: PagerState = remember { PagerState() },
itemsCount: Int,
itemContent: @Composable (index: Int) -> Unit
) {
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(count = itemsCount, state = pagerState) { page ->
itemContent(page)
}
// you can remove the surface in case you don't want
// the transparant bacground
Surface(
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.BottomCenter),
shape = CircleShape,
color = Color.Black.copy(alpha = 0.5f)
) {
DotsIndicator(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
totalDots = itemsCount,
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
dotSize = 8.dp
)
}
}
}
@Composable
fun DotsIndicator(
modifier: Modifier = Modifier,
totalDots: Int,
selectedIndex: Int,
selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */,
unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */,
dotSize: Dp
) {
LazyRow(
modifier = modifier
.wrapContentWidth()
.wrapContentHeight()
) {
items(totalDots) { index ->
IndicatorDot(
color = if (index == selectedIndex) selectedColor else unSelectedColor,
size = dotSize
)
if (index != totalDots - 1) {
Spacer(modifier = Modifier.padding(horizontal = 2.dp))
}
}
}
}
@Composable
fun IndicatorDot(
modifier: Modifier = Modifier,
size: Dp,
color: Color
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(color)
)
}
@@ -0,0 +1,101 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier
) {
OutlinedTextField(
value = currentText,
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
focusRequester.requestFocus()
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp)
) {
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
.clickable {
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
}
}
}
}
}
}
@@ -19,15 +19,15 @@ import kotlinx.coroutines.withContext
@Composable
fun UrlPreview(url: String, urlText: String) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.url == url) {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
var context = LocalContext.current
val context = LocalContext.current
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
@@ -34,12 +34,18 @@ fun UrlPreviewCard(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
val url = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(URL(previewInfo.url), previewInfo.image).toString()
URL(url, previewInfo.image).toString()
} else {
previewInfo.image
}
@@ -51,12 +57,23 @@ fun UrlPreviewCard(
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -19,21 +19,37 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
@Composable
fun VideoView(videoUri: String) {
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
)
prepare()
}
}
val playerView = remember {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
innerOnDialog(it)
}
}
}
}
DisposableEffect(exoPlayer) {
exoPlayer.setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
)
exoPlayer.prepare()
onDispose {
exoPlayer.release()
}
@@ -42,14 +58,7 @@ fun VideoView(videoUri: String) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
playerView
}
)
}
@@ -1,63 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale *= event.calculateZoom()
val offset = event.calculatePan()
offsetX += offset.x
offsetY += offset.y
} while (event.changes.any { it.pressed })
}
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
)
)
}
}
@@ -18,22 +18,29 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) {
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -41,49 +48,103 @@ fun ZoomableImageView(word: String) {
mutableStateOf(false)
}
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
)
)
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
if (imageExtension.matcher(word).matches()) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
ClickableUrl(urlText = "$word ", url = word)
LoadingAnimation(indicatorSize = 15.dp)
}
} else {
VideoView(word) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
modifier = Modifier.padding(10.dp)
) {
Column() {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = imageUrl)
SaveToGallery(url = allImages[pagerState.currentPage])
}
ZoomableAsyncImage(imageUrl)
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(imageUrl: String) {
if (imageExtension.matcher(imageUrl).matches()) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(imageUrl)
}
}
}
@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val bookmarks = account.userProfile().latestBookmarkList
val notes = bookmarks?.privateTaggedEvents(privKey)
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.privateTaggedAddresses(privKey)
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -0,0 +1,20 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
val notes = bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -10,18 +10,49 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels()) &&
// does not show people the user already follows
(it.author !in account.userProfile().follows)
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.reversed()
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val followUsers = account.followingKeySet()
val notes = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
}
.toList()
val longFormNotes = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
}
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
return LocalCache.notes.values
.asSequence()
.filter {
(
it.event is TextNoteEvent ||
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent
) &&
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
}
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
@@ -11,13 +10,15 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author in user.follows &&
(it.event is TextNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
.sortedBy { it.createdAt() }
@@ -12,11 +12,13 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
(it.event is TextNoteEvent || it.event is RepostEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -24,8 +26,8 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
(it.event is LongTextNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
@@ -8,37 +9,39 @@ object NotificationFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile()
.taggedPosts
val loggedInUser = account.userProfile()
return LocalCache.notes.values
.asSequence()
.filter {
it.author == null ||
(!account.isHidden(it.author!!) && it.author != account.userProfile())
}
.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent
it.event !is BadgeProfilesEvent &&
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
}
.filter { it ->
it.event !is TextNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.any { it.author == loggedInUser } == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is RepostEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
fun isDifferentAccount(account: Account): Boolean {
return this::account.isInitialized && this.account != account
}
}
@@ -0,0 +1,31 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.users[userId]
}
override fun feed(): List<Note> {
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
LocalCache.checkGetOrCreateNote(it)
}?.toSet() ?: emptySet()
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
LocalCache.getOrCreateAddressableNote(it)
}?.toSet() ?: emptySet()
return (notes + addresses)
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.reversed()
}
}

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